Line data Source code
1 : /*
2 : * Copyright (C) 2026 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 : /* This file exports two components
7 :
8 : - a FileChooser component that can be used with "Dialogs.show" to
9 : show a configurable, general purpose file chooser dialog
10 :
11 : - a DialogFileChooserInput component that can be used with
12 : "useDialogState" etc as a text input field for pathnames in
13 : dialogs.
14 :
15 : A FileChooser is configured via these properties:
16 :
17 : - title: string
18 :
19 : The title in the header of the dialog.
20 :
21 : - filters?: undefined | FileChooserFilter[];
22 :
23 : A list of "prepared filters". A filter looks like this:
24 :
25 : interface FileChooserFilter {
26 : label: string;
27 : filter: (name: string, type: string) => boolean,
28 : }
29 :
30 : The "filter" function will be called with the base name of a file
31 : and its type. The type is the string returned by "fsinfo", such as
32 : "reg", "dir", "blk", etc.
33 :
34 : - shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>)
35 :
36 : A list of additional shortcuts to display in the sidebar of the
37 : dialog. A shortcut looks like this:
38 :
39 : interface FileChooserShortcut {
40 : label: string;
41 : path: string;
42 : }
43 :
44 : The path should point to a existing directory.
45 :
46 : Instead of a array of shortcuts, you can also pass a async function
47 : that will return the array. The function will be called each time
48 : when the dialog is opened.
49 :
50 : - collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
51 :
52 : A list of additional collections. A collection is a list of files
53 : that are not necessarily in the same directory. The "Recent" entry
54 : in the sidebar is a collection, for example. A collection looks like this:
55 :
56 : interface FileChooserCollection {
57 : label: string;
58 : emptyLabel: string;
59 : list: () => Promise<string[]>;
60 : }
61 :
62 : The "list" function should return absolute pathnames. The
63 : FileChooser will query their actual types and filter out any entry
64 : that does not actually exist. The files will not be further
65 : re-ordered before displaying them. If you want them to be sorted,
66 : you need to do that before returning the array.
67 :
68 : - onlyDirectories?: undefined | boolean;
69 :
70 : If true, show only directories and let the user select a
71 : directory. If false, directories are of course shown, but they
72 : can't be selected.
73 :
74 : - superuser?: cockpit.SuperuserMode;
75 :
76 : The "superuser" option to use when listing files, etc.
77 :
78 : - recentKey?: undefined | string;
79 :
80 : A key for localStorage to retrieve the list of recent files.
81 : Defaults to "recent-files".
82 :
83 : - actionLabel?: string;
84 :
85 : The label to put into the apply button of the file chooser.
86 : Defaults to "Select".
87 :
88 : If you use the FileChooser by itself (and not via
89 : DialogFileChooserInput), you can also specify the following
90 : properties:
91 :
92 : - path: string;
93 :
94 : The initial path to open at.
95 :
96 : - action: (path: string) => Promise<void>
97 :
98 : A function to run when the user clicks the apply button. When this
99 : function throws an exception, the dialog does not close and the
100 : error is shown in the dialog itself.
101 :
102 : The DialogFileChooserInput has the same properties as a
103 : DialogTextInput plus this:
104 :
105 : - fileChooserProps
106 :
107 : The properties to use when opening the FileChooser dialog, such as
108 : "title", "shortcuts", etc.
109 :
110 : */
111 :
112 2 : import cockpit from "cockpit";
113 2 : import React, { useRef, useCallback, useEffect } from "react";
114 :
115 : import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
116 : import { Table, Caption, Tbody, Tr, Td } from '@patternfly/react-table';
117 : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
118 : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
119 : import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
120 : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
121 : import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
122 : import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons';
123 : import {
124 : TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
125 : } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
126 : import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
127 : import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
128 : import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
129 : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
130 : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
131 :
132 : import { KebabDropdown } from "cockpit-components-dropdown";
133 :
134 : import { useDialogs, WithDialogs } from 'dialogs';
135 : import { FsInfoClient, fsinfo } from "cockpit/fsinfo";
136 : import { basename, dirname } from "cockpit-path";
137 :
138 : import {
139 : useDialogState_async,
140 : DialogState,
141 : DialogField,
142 : DialogErrorMessage,
143 : DialogHelperText,
144 : OptionalFormGroup,
145 : DialogActionButton,
146 : } from 'cockpit/dialog';
147 :
148 : import "./FileChooser.css";
149 :
150 2 : const _ = cockpit.gettext;
151 :
152 1 : async function getHomeDir(): Promise<string> {
153 1 : if (!cockpit.info.user)
154 1 : await cockpit.init();
155 1 : return cockpit.info.user.home;
156 1 : }
157 :
158 1 : async function getDownloadDir(): Promise<string | null> {
159 1 : try {
160 1 : return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
161 0 : } catch (ex) {
162 0 : console.warn("Can't determine downloads directory", String(ex));
163 0 : return null;
164 0 : }
165 1 : }
166 :
167 1 : async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise<FileChooserShortcut[]> {
168 1 : const home = await getHomeDir();
169 1 : const dd = await getDownloadDir();
170 :
171 1 : return [
172 1 : { label: _("Home"), path: home },
173 0 : ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
174 1 : ...shortcuts,
175 1 : ];
176 1 : }
177 :
178 1 : const FileIcon = () => {
179 1 : return (
180 1 : <svg
181 1 : height="1em"
182 1 : width="1em"
183 1 : xmlns="http://www.w3.org/2000/svg"
184 1 : viewBox="0 0 1536 1792"
185 1 : fill="currentColor"
186 : >
187 1 : <path d="M1468 380c37 37 68 111 68 164v1152c0 53-43 96-96 96H96c-53 0-96-43-96-96V96C0 43 43 0 96 0h896c53 0 127 31 164 68zm-444-244v376h376c-6-17-15-34-22-41l-313-313c-7-7-24-16-41-22zm384 1528V640H992c-53 0-96-43-96-96V128H128v1536z" />
188 1 : </svg>
189 : );
190 1 : };
191 :
192 1 : function path_join(dir: string, base: string) {
193 1 : return (dir == "/" ? "" : dir) + "/" + base;
194 1 : }
195 :
196 : interface FileInfo {
197 : type: string;
198 : name: string;
199 : }
200 :
201 2 : class FileError {
202 : message: string;
203 :
204 1 : constructor(message: string) {
205 1 : this.message = message;
206 1 : }
207 2 : }
208 :
209 1 : function watchFiles(
210 1 : path: string,
211 1 : onlyDirectories: boolean,
212 1 : superuser: cockpit.SuperuserMode,
213 1 : callback: (files: FileError | FileInfo[]) => void,
214 1 : ): FsInfoClient {
215 1 : const client = new FsInfoClient(
216 1 : path,
217 1 : ["type", "entries", "target", "targets"],
218 1 : {
219 1 : follow: true,
220 1 : ...(superuser ? { superuser } : { })
221 1 : }
222 1 : );
223 :
224 1 : client.on("close", message => {
225 0 : if ("message" in message && typeof message.message == "string")
226 0 : callback(new FileError(message.message));
227 1 : });
228 :
229 1 : client.on("change", state => {
230 1 : if (state.error) {
231 1 : callback(new FileError(state.error.message));
232 1 : return;
233 1 : }
234 :
235 1 : if (!state.info)
236 1 : return;
237 :
238 1 : const info = state.info;
239 :
240 0 : if (!(info.type && info.entries && info.targets)) {
241 0 : callback(new FileError(_("Permission denied")));
242 0 : return;
243 0 : }
244 :
245 0 : if (info.type != "dir") {
246 0 : callback(new FileError(_("Not a directory")));
247 0 : return;
248 0 : }
249 :
250 1 : const result: FileInfo[] = [];
251 1 : for (const name in info.entries) {
252 1 : let entry = info.entries[name];
253 1 : if (entry.type == "lnk" && entry.target)
254 1 : entry = info.entries[entry.target] || info.targets[entry.target];
255 :
256 1 : cockpit.assert(entry.type);
257 1 : if (!onlyDirectories || entry.type == "dir")
258 1 : result.push({ type: entry.type, name });
259 1 : }
260 :
261 1 : result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
262 1 : callback(result);
263 1 : });
264 :
265 1 : return client;
266 1 : }
267 :
268 1 : async function getFileInfos(
269 1 : paths: string[],
270 1 : onlyDirectories: boolean,
271 1 : superuser: cockpit.SuperuserMode,
272 1 : ): Promise<FileInfo[]> {
273 1 : const res: FileInfo[] = [];
274 :
275 1 : for (const p of paths) {
276 1 : try {
277 1 : const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
278 1 : if (info.type && (!onlyDirectories || info.type == "dir"))
279 1 : res.push({ name: p, type: info.type });
280 1 : } catch (ex) {
281 1 : if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem != "not-found"))
282 1 : console.error("Failed to get file type:", p);
283 1 : }
284 1 : }
285 :
286 1 : return res;
287 1 : }
288 :
289 1 : async function listRecent(recentKey: string): Promise<string[]> {
290 1 : const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
291 1 : if (Array.isArray(recent)) {
292 1 : return recent.filter(r => typeof r == "string");
293 0 : } else {
294 0 : return [];
295 0 : }
296 1 : }
297 :
298 1 : function boldify(name: string, filterText: string): React.ReactNode {
299 1 : if (!filterText)
300 1 : return name;
301 1 : const parts: React.ReactNode[] = [];
302 1 : let pos;
303 1 : while ((pos = name.indexOf(filterText)) >= 0) {
304 1 : parts.push(name.substring(0, pos));
305 1 : parts.push(<u key={pos}>{name.substring(pos, pos + filterText.length)}</u>);
306 1 : name = name.substring(pos + filterText.length);
307 1 : }
308 1 : if (name)
309 1 : parts.push(name);
310 1 : return parts;
311 1 : }
312 :
313 : export interface FileChooserFilter {
314 : label: string;
315 : filter: (name: string, type: string) => boolean,
316 : }
317 :
318 : export interface FileChooserShortcut {
319 : label: string;
320 : path: string;
321 : }
322 :
323 : export interface FileChooserCollection {
324 : label: string;
325 : emptyLabel: string;
326 : list: () => Promise<string[]>;
327 : }
328 :
329 : export interface FileChooserProps {
330 : title: string;
331 : shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
332 : filters?: undefined | FileChooserFilter[];
333 : collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
334 : onlyDirectories?: undefined | boolean;
335 : superuser?: cockpit.SuperuserMode;
336 : recentKey?: undefined | string;
337 : actionLabel?: string;
338 : }
339 :
340 : interface FileChooserValues {
341 : path: string;
342 : collection: null | FileChooserCollection;
343 : files: null | FileError | FileInfo[];
344 : selected: null | FileInfo;
345 : textFilter: string;
346 : filters: FileChooserFilter[];
347 : filter: FileChooserFilter;
348 : recent_collection: FileChooserCollection;
349 : shortcuts: FileChooserShortcut[];
350 : collections: FileChooserCollection[];
351 : showHidden: boolean;
352 : }
353 :
354 1 : export const FileChooser = ({
355 1 : title,
356 1 : shortcuts = [],
357 1 : filters = [],
358 1 : collections = [],
359 1 : onlyDirectories = false,
360 1 : superuser,
361 1 : recentKey = "recent-files",
362 1 : actionLabel,
363 1 : path = "",
364 1 : action,
365 1 : } : {
366 : path?: string,
367 : action: (path: string) => Promise<void>,
368 1 : } & FileChooserProps) => {
369 1 : const Dialogs = useDialogs();
370 1 : const textInputRef = useRef<HTMLInputElement>(null);
371 1 : const fsInfoClientRef = useRef<FsInfoClient | null>(null);
372 :
373 1 : function focusFilter() {
374 1 : textInputRef.current?.focus();
375 1 : }
376 :
377 1 : useEffect(() => {
378 0 : textInputRef.current?.focus();
379 1 : }, []);
380 :
381 1 : async function init(): Promise<FileChooserValues> {
382 1 : const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
383 :
384 1 : const recent_collection = {
385 1 : label: _("Recent"),
386 1 : emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
387 1 : list: () => listRecent(recentKey)
388 1 : };
389 :
390 1 : const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
391 1 : const collections_list = Array.isArray(collections) ? collections : await collections();
392 :
393 1 : return {
394 1 : path,
395 1 : collection: path == "" ? recent_collection : null,
396 1 : files: null,
397 1 : selected: null,
398 1 : textFilter: "",
399 1 : filters: all_filters,
400 1 : filter: all_filters[0],
401 1 : recent_collection,
402 1 : shortcuts: await stdShortcuts(shortcuts_list),
403 1 : collections: collections_list,
404 1 : showHidden: false,
405 1 : };
406 1 : }
407 :
408 1 : const dlg = useDialogState_async(init);
409 :
410 1 : const setPath = useCallback(
411 1 : (dlg: DialogState<FileChooserValues>, path: string) => {
412 1 : dlg.field("path").set(path);
413 1 : dlg.field("collection").set(null);
414 1 : dlg.field("selected").set(null);
415 1 : dlg.field("files").set(null);
416 :
417 1 : if (fsInfoClientRef.current)
418 1 : fsInfoClientRef.current.close();
419 :
420 1 : fsInfoClientRef.current = watchFiles(
421 1 : path,
422 1 : onlyDirectories,
423 1 : superuser,
424 1 : files => {
425 1 : dlg.field("files").set(files);
426 1 : }
427 1 : );
428 1 : },
429 1 : [onlyDirectories, superuser],
430 1 : );
431 :
432 1 : const setCollection = useCallback(
433 1 : (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
434 1 : dlg.field("path").set("");
435 1 : dlg.field("collection").set(collection);
436 1 : dlg.field("selected").set(null);
437 1 : dlg.field("files").set(null);
438 :
439 1 : if (fsInfoClientRef.current)
440 1 : fsInfoClientRef.current.close();
441 :
442 1 : fsInfoClientRef.current = null;
443 1 : dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
444 1 : },
445 1 : [onlyDirectories, superuser],
446 1 : );
447 :
448 1 : useEffect(() => {
449 1 : if (dlg instanceof DialogState) {
450 1 : if (dlg.values.collection)
451 1 : setCollection(dlg, dlg.values.collection);
452 : else
453 1 : setPath(dlg, dlg.values.path);
454 1 : }
455 1 : }, [dlg, setPath, setCollection]);
456 :
457 1 : function full_path(path: string, selected: string) {
458 1 : if (path == "")
459 1 : return selected;
460 : else
461 1 : return path_join(path, selected);
462 1 : }
463 :
464 1 : function selected_path(): string | null {
465 1 : if (!(dlg instanceof DialogState))
466 1 : return null;
467 :
468 1 : const { selected, path } = dlg.values;
469 :
470 1 : if (onlyDirectories) {
471 1 : if (!selected && path != "")
472 1 : return path;
473 1 : else if (selected && selected.type == "dir")
474 1 : return full_path(path, selected.name);
475 1 : } else {
476 1 : if (selected && selected.type != "dir")
477 1 : return full_path(path, selected.name);
478 1 : }
479 :
480 1 : return null;
481 1 : }
482 :
483 1 : async function onAction() {
484 1 : const full = selected_path();
485 1 : cockpit.assert(full);
486 1 : rememberRecent(full, recentKey);
487 1 : await action(full);
488 1 : }
489 :
490 1 : function breadcrumbs(dlg: DialogState<FileChooserValues>) {
491 1 : const { path } = dlg.values;
492 :
493 1 : if (path == "") {
494 : // Collection
495 1 : return null;
496 1 : } else {
497 1 : const dirs = ["/"].concat(path.split("/").filter(d => !!d));
498 1 : const crumbs: React.ReactNode[] = [];
499 1 : let full = "/";
500 1 : dirs.forEach((d, i) => {
501 1 : if (d != "/")
502 1 : full = path_join(full, d);
503 1 : const path = full;
504 1 : crumbs.push(
505 1 : <BreadcrumbItem
506 1 : key={i}
507 1 : to="#"
508 1 : onClick={
509 1 : (event) => {
510 1 : setPath(dlg, path);
511 1 : event.preventDefault();
512 1 : }
513 : }
514 1 : isActive={i == dirs.length - 1}
515 : >
516 1 : { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
517 1 : </BreadcrumbItem>
518 1 : );
519 1 : });
520 :
521 1 : if (crumbs.length > 0) {
522 1 : return (
523 1 : <Breadcrumb>
524 1 : {crumbs}
525 1 : </Breadcrumb>
526 : );
527 1 : }
528 1 : }
529 1 : }
530 :
531 1 : function header(dlg: DialogState<FileChooserValues>) {
532 1 : const preparedFilters = (
533 1 : dlg.values.filters.length > 1 &&
534 1 : <ToggleGroup>
535 : {
536 1 : dlg.values.filters.map(f => {
537 1 : return (
538 1 : <ToggleGroupItem
539 1 : key={f.label}
540 1 : isSelected={f == dlg.values.filter}
541 1 : onChange={() => {
542 1 : dlg.field("filter").set(f);
543 1 : focusFilter();
544 1 : }}
545 1 : text={f.label}
546 1 : />
547 : );
548 1 : })
549 : }
550 1 : </ToggleGroup>
551 : );
552 :
553 1 : const textFilter = (
554 1 : <TextInput
555 1 : ref={textInputRef}
556 1 : placeholder={_("Type to filter")}
557 1 : value={dlg.values.textFilter}
558 1 : onChange={(_event, value) => dlg.field("textFilter").set(value)}
559 1 : />
560 : );
561 :
562 1 : function shortcut(sc: FileChooserShortcut) {
563 1 : return (
564 1 : <DropdownItem
565 1 : key={sc.label}
566 1 : onClick={() => setPath(dlg, sc.path)}
567 1 : className="file-chooser-hide-on-wide"
568 : >
569 1 : {sc.label}
570 1 : </DropdownItem>
571 : );
572 1 : }
573 :
574 1 : function collection(cl: FileChooserCollection) {
575 1 : return (
576 1 : <DropdownItem
577 1 : key={cl.label}
578 0 : onClick={() => setCollection(dlg, cl)}
579 1 : className="file-chooser-hide-on-wide"
580 : >
581 1 : {cl.label}
582 1 : </DropdownItem>
583 : );
584 1 : }
585 :
586 1 : return (
587 1 : <Flex>
588 1 : <FlexItem>
589 1 : {textFilter}
590 1 : </FlexItem>
591 1 : <FlexItem>
592 1 : {preparedFilters}
593 1 : </FlexItem>
594 1 : <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
595 1 : <KebabDropdown
596 1 : dropdownItems={
597 1 : [
598 1 : <DropdownItem
599 1 : key="jump"
600 1 : onClick={
601 0 : () => {
602 0 : cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
603 0 : }
604 : }
605 1 : isDisabled={dlg.values.path === ""}
606 : >
607 1 : {_("Open in file browser")}
608 1 : </DropdownItem>,
609 1 : <DropdownItem
610 1 : key="showhide"
611 1 : onClick={
612 1 : () => {
613 1 : dlg.field("showHidden").set(!dlg.values.showHidden);
614 1 : }
615 : }
616 : >
617 1 : {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
618 1 : </DropdownItem>,
619 1 : <Divider key="divider" className="file-chooser-hide-on-wide" />,
620 1 : collection(dlg.values.recent_collection),
621 1 : ...dlg.values.shortcuts.map(shortcut),
622 1 : shortcut({ label: _("Filesystem"), path: "/" }),
623 1 : ...dlg.values.collections.map(collection)
624 1 : ]
625 : }
626 1 : />
627 1 : </FlexItem>
628 1 : </Flex>
629 : );
630 1 : }
631 :
632 1 : function formatIcon(f: FileInfo): React.ReactNode {
633 1 : if (f.type == "dir")
634 1 : return <FolderIcon />;
635 : else
636 1 : return <FileIcon />;
637 1 : }
638 :
639 1 : function sidebar(dlg: DialogState<FileChooserValues>) {
640 1 : function shortcut(sc: FileChooserShortcut) {
641 1 : return (
642 1 : <Tr
643 1 : key={sc.label}
644 1 : isClickable
645 1 : isSelectable
646 1 : isRowSelected={dlg.values.path == sc.path}
647 1 : onRowClick={
648 1 : () => {
649 1 : setPath(dlg, sc.path);
650 1 : focusFilter();
651 1 : }
652 : }
653 : >
654 1 : <Td>{sc.label}</Td>
655 1 : </Tr>
656 : );
657 1 : }
658 :
659 1 : function collection(col: FileChooserCollection) {
660 1 : return (
661 1 : <Tr
662 1 : key={col.label}
663 1 : isClickable
664 1 : isSelectable
665 1 : isRowSelected={dlg.values.collection == col}
666 1 : onRowClick={
667 1 : () => {
668 1 : setCollection(dlg, col);
669 1 : focusFilter();
670 1 : }
671 : }
672 : >
673 1 : <Td>{col.label}</Td>
674 1 : </Tr>
675 : );
676 1 : }
677 :
678 1 : return (
679 1 : <Table variant="compact" borders={false}>
680 1 : <Tbody>
681 1 : { collection(dlg.values.recent_collection) }
682 1 : { dlg.values.shortcuts.map(shortcut) }
683 1 : { shortcut({ label: _("Filesystem"), path: "/" }) }
684 1 : { dlg.values.collections.map(collection) }
685 1 : </Tbody>
686 1 : </Table>
687 : );
688 1 : }
689 :
690 1 : function listing(dlg: DialogState<FileChooserValues>) {
691 1 : function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
692 1 : return (
693 1 : <Caption>
694 1 : <EmptyState
695 1 : titleText={content}
696 1 : icon={icon}
697 : >
698 1 : { (clearFilters > 0) &&
699 1 : <EmptyStateActions>
700 1 : <Button
701 1 : variant="link"
702 1 : onClick={() => {
703 1 : if (clearFilters == 3) {
704 1 : dlg.field("showHidden").set(true);
705 1 : } else {
706 1 : dlg.field("textFilter").set("");
707 1 : if (clearFilters > 1)
708 1 : dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
709 1 : }
710 1 : focusFilter();
711 1 : }}
712 : >
713 1 : {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
714 1 : </Button>
715 1 : </EmptyStateActions>
716 : }
717 1 : </EmptyState>
718 1 : </Caption>
719 : );
720 1 : }
721 :
722 1 : function listingBody() {
723 1 : const files = dlg.values.files;
724 :
725 1 : if (files == null)
726 1 : return emptyState("", Spinner);
727 :
728 1 : if (files instanceof FileError)
729 1 : return emptyState(files.message, FolderIcon);
730 :
731 1 : if (files.length == 0) {
732 1 : if (dlg.values.collection) {
733 1 : return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
734 1 : } else if (!onlyDirectories) {
735 1 : return emptyState(_("Directory is empty"), FolderIcon);
736 0 : } else {
737 0 : return emptyState(_("Directory has no sub-directories"), FolderIcon);
738 0 : }
739 1 : }
740 :
741 1 : const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
742 1 : if (withoutHidden.length == 0)
743 1 : return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
744 :
745 1 : const preFiltered = withoutHidden.filter(
746 1 : f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
747 1 : );
748 1 : if (preFiltered.length == 0)
749 1 : return emptyState(_("No matching results"), SearchIcon, 2);
750 :
751 1 : const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
752 1 : if (filtered.length == 0)
753 1 : return emptyState(_("No matching results"), SearchIcon, 1);
754 :
755 1 : return (
756 1 : <Tbody>
757 : {
758 1 : filtered.map(
759 1 : (f, idx) => {
760 1 : let name, location;
761 1 : if (dlg.values.path == "") {
762 1 : name = basename(f.name);
763 1 : location = dirname(f.name);
764 1 : } else {
765 1 : name = f.name;
766 1 : }
767 1 : return (
768 1 : <Tr
769 1 : className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
770 1 : key={idx}
771 1 : data-name={name}
772 1 : onRowClick={
773 1 : () => {
774 1 : dlg.field("selected").set(f);
775 1 : focusFilter();
776 1 : }
777 : }
778 1 : onDoubleClick={
779 1 : event => {
780 1 : event.preventDefault();
781 1 : if (f.type == "dir")
782 1 : setPath(dlg, full_path(dlg.values.path, f.name));
783 1 : dlg.field("textFilter").set("");
784 1 : focusFilter();
785 1 : }
786 : }
787 1 : isClickable
788 : >
789 1 : <Td>
790 1 : {formatIcon(f)}
791 :
792 1 : {boldify(name, dlg.values.textFilter)}
793 1 : </Td>
794 1 : { location && <Td>{location}</Td> }
795 1 : </Tr>
796 : );
797 1 : }
798 1 : )
799 : }
800 1 : </Tbody>
801 : );
802 1 : }
803 :
804 1 : return (
805 1 : <Table variant="compact" borders={false}>
806 1 : { listingBody() }
807 1 : </Table>
808 : );
809 1 : }
810 :
811 1 : return (
812 1 : <Modal
813 1 : isOpen
814 1 : variant="large"
815 1 : position="top"
816 1 : onClose={Dialogs.close}
817 1 : className="file-chooser"
818 : >
819 1 : <ModalHeader
820 1 : title={title}
821 1 : description={<DialogErrorMessage dialog={dlg} />}
822 1 : />
823 1 : <ModalBody>
824 1 : <div className="file-chooser-body">
825 1 : <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
826 : {
827 1 : dlg instanceof DialogState
828 1 : ? sidebar(dlg)
829 1 : : <Bullseye><Spinner /></Bullseye>
830 : }
831 1 : </div>
832 1 : <div className="file-chooser-listing-header">
833 1 : { dlg instanceof DialogState && header(dlg) }
834 1 : </div>
835 1 : <div className="file-chooser-listing-breadcrumbs">
836 1 : { dlg instanceof DialogState && breadcrumbs(dlg) }
837 1 : </div>
838 1 : <div className="file-chooser-listing-body">
839 1 : { dlg instanceof DialogState && listing(dlg) }
840 1 : </div>
841 1 : </div>
842 1 : </ModalBody>
843 1 : <ModalFooter>
844 1 : <DialogActionButton
845 1 : dialog={dlg}
846 1 : isAriaDisabled={selected_path() === null}
847 1 : action={onAction}
848 1 : onClose={Dialogs.close}
849 : >
850 1 : {actionLabel || _("Select")}
851 1 : </DialogActionButton>
852 1 : </ModalFooter>
853 1 : </Modal>
854 : );
855 1 : };
856 :
857 2 : const FileChooserButton = ({
858 2 : value,
859 2 : onChoose,
860 2 : props,
861 2 : } : {
862 : value: string,
863 : onChoose: (path: string) => void,
864 : props: FileChooserProps,
865 2 : }) => {
866 2 : const Dialogs = useDialogs();
867 :
868 2 : return (
869 2 : <Button
870 2 : variant="plain"
871 2 : icon={<FolderOpenIcon />}
872 2 : onClick={
873 1 : async () => {
874 1 : Dialogs.show(
875 1 : <FileChooser
876 1 : path={value[0] == "/" ? dirname(value) : ""}
877 1 : action={async path => onChoose(path)}
878 1 : {...props}
879 1 : />
880 1 : );
881 1 : }
882 : }
883 2 : />
884 : );
885 2 : };
886 :
887 2 : export const FileChooserInput = ({
888 2 : ouiaId,
889 2 : placeholder = "",
890 2 : value,
891 2 : onChange,
892 2 : isDisabled = false,
893 2 : fileChooserProps,
894 2 : } : {
895 : ouiaId?: undefined | string;
896 : placeholder?: string,
897 : value: string,
898 : onChange: (path: string, from_dialog: boolean) => void,
899 : isDisabled?: boolean,
900 : fileChooserProps: FileChooserProps,
901 2 : }) => {
902 2 : return (
903 2 : <TextInputGroup
904 2 : isDisabled={isDisabled}
905 2 : data-ouia-component-id={ouiaId}
906 : >
907 2 : <TextInputGroupMain
908 2 : value={value}
909 2 : placeholder={placeholder}
910 1 : onChange={(_event, value) => onChange(value, false)}
911 2 : autoComplete="off"
912 2 : />
913 2 : <TextInputGroupUtilities>
914 2 : <WithDialogs>
915 2 : <FileChooserButton
916 2 : value={value}
917 1 : onChoose={value => onChange(value, true)}
918 2 : props={fileChooserProps}
919 2 : />
920 2 : </WithDialogs>
921 2 : </TextInputGroupUtilities>
922 2 : </TextInputGroup>
923 : );
924 2 : };
925 :
926 2 : export const DialogFileChooserInput = ({
927 2 : field,
928 2 : label,
929 2 : placeholder = "",
930 2 : explanation,
931 2 : warning,
932 2 : excuse,
933 2 : fileChooserProps,
934 2 : } : {
935 : field: DialogField<string>,
936 : label: string,
937 : placeholder?: string,
938 : explanation?: React.ReactNode,
939 : warning?: React.ReactNode,
940 : excuse?: string | null | undefined | false,
941 : fileChooserProps: FileChooserProps,
942 2 : }) => {
943 2 : return (
944 2 : <OptionalFormGroup
945 2 : label={label}
946 : >
947 2 : <FileChooserInput
948 2 : ouiaId={field.ouia_id()}
949 2 : placeholder={placeholder}
950 2 : value={field.get()}
951 1 : onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
952 2 : isDisabled={!!excuse}
953 2 : fileChooserProps={fileChooserProps}
954 2 : />
955 2 : <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
956 2 : </OptionalFormGroup>
957 : );
958 2 : };
959 :
960 1 : export function rememberRecent(name: string, recentKey: string = "recent-files") {
961 1 : const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
962 1 : if (Array.isArray(value)) {
963 1 : const recent = value.filter(r => typeof r == "string" && r != name);
964 1 : recent.unshift(name);
965 1 : window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
966 1 : }
967 1 : }
|