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, 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 : return (await cockpit.user()).home;
154 1 : }
155 :
156 1 : async function getDownloadDir(): Promise<string | null> {
157 1 : try {
158 1 : return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
159 0 : } catch (ex) {
160 0 : console.warn("Can't determine downloads directory", String(ex));
161 0 : return null;
162 0 : }
163 1 : }
164 :
165 1 : async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise<FileChooserShortcut[]> {
166 1 : const home = await getHomeDir();
167 1 : const dd = await getDownloadDir();
168 :
169 1 : return [
170 1 : { label: _("Home"), path: home },
171 0 : ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
172 1 : ...shortcuts,
173 1 : ];
174 1 : }
175 :
176 1 : const OutlineFileIcon = () => {
177 1 : return (
178 1 : <svg
179 1 : height="1em"
180 1 : width="1em"
181 1 : xmlns="http://www.w3.org/2000/svg"
182 1 : viewBox="0 0 1536 1792"
183 1 : fill="currentColor"
184 : >
185 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" />
186 1 : </svg>
187 : );
188 1 : };
189 :
190 1 : function path_join(dir: string, base: string) {
191 1 : return (dir == "/" ? "" : dir) + "/" + base;
192 1 : }
193 :
194 : interface FileInfo {
195 : type: string;
196 : name: string;
197 : }
198 :
199 2 : class FileError {
200 : message: string;
201 :
202 1 : constructor(message: string) {
203 1 : this.message = message;
204 1 : }
205 2 : }
206 :
207 1 : function watchFiles(
208 1 : path: string,
209 1 : onlyDirectories: boolean,
210 1 : superuser: cockpit.SuperuserMode,
211 1 : callback: (files: FileError | FileInfo[]) => void,
212 1 : ): FsInfoClient {
213 1 : const client = new FsInfoClient(
214 1 : path,
215 1 : ["type", "entries", "target", "targets"],
216 1 : {
217 1 : follow: true,
218 0 : ...(superuser ? { superuser } : { })
219 1 : }
220 1 : );
221 :
222 1 : client.on("close", message => {
223 0 : if ("message" in message && typeof message.message == "string")
224 0 : callback(new FileError(message.message));
225 1 : });
226 :
227 1 : client.on("change", state => {
228 1 : if (state.error) {
229 1 : callback(new FileError(state.error.message));
230 1 : return;
231 1 : }
232 :
233 1 : if (!state.info)
234 1 : return;
235 :
236 1 : const info = state.info;
237 :
238 0 : if (!(info.type && info.entries && info.targets)) {
239 0 : callback(new FileError(_("Permission denied")));
240 0 : return;
241 0 : }
242 :
243 0 : if (info.type != "dir") {
244 0 : callback(new FileError(_("Not a directory")));
245 0 : return;
246 0 : }
247 :
248 1 : const result: FileInfo[] = [];
249 1 : for (const name in info.entries) {
250 1 : let entry = info.entries[name];
251 1 : if (entry.type == "lnk" && entry.target)
252 1 : entry = info.entries[entry.target] || info.targets[entry.target];
253 :
254 1 : if (entry && entry.type) {
255 1 : if (!onlyDirectories || entry.type == "dir")
256 1 : result.push({ type: entry.type, name });
257 1 : }
258 1 : }
259 :
260 1 : function orderType(t: string) {
261 1 : if (t == "dir")
262 1 : return "a";
263 : else
264 1 : return "b";
265 1 : }
266 :
267 1 : result.sort((a, b) => (orderType(a.type) + a.name).localeCompare(orderType(b.type) + b.name));
268 1 : callback(result);
269 1 : });
270 :
271 1 : return client;
272 1 : }
273 :
274 1 : async function getFileInfos(
275 1 : paths: string[],
276 1 : onlyDirectories: boolean,
277 1 : superuser: cockpit.SuperuserMode,
278 1 : ): Promise<FileInfo[]> {
279 1 : const res: FileInfo[] = [];
280 :
281 1 : for (const p of paths) {
282 1 : try {
283 0 : const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
284 1 : if (info.type && (!onlyDirectories || info.type == "dir"))
285 1 : res.push({ name: p, type: info.type });
286 0 : } catch (ex) {
287 0 : if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found"))
288 0 : console.error("Failed to get file type:", p);
289 0 : }
290 1 : }
291 :
292 1 : return res;
293 1 : }
294 :
295 1 : function readRecent(recentKey: string): string[] {
296 1 : try {
297 1 : const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
298 1 : if (Array.isArray(value))
299 1 : return value.filter(r => typeof r == "string");
300 0 : } catch (ex) {
301 0 : console.warn("Failed to parse recent files", String(ex));
302 0 : }
303 :
304 0 : return [];
305 1 : }
306 :
307 1 : function boldify(name: string, filterText: string): React.ReactNode {
308 1 : if (!filterText)
309 1 : return name;
310 1 : const parts: React.ReactNode[] = [];
311 1 : let pos;
312 1 : let key = 0;
313 1 : while ((pos = name.indexOf(filterText)) >= 0) {
314 1 : parts.push(name.substring(0, pos));
315 1 : parts.push(<u key={key++}>{name.substring(pos, pos + filterText.length)}</u>);
316 1 : name = name.substring(pos + filterText.length);
317 1 : }
318 1 : if (name)
319 1 : parts.push(name);
320 1 : return parts;
321 1 : }
322 :
323 : export interface FileChooserFilter {
324 : label: string;
325 : filter: (name: string, type: string) => boolean,
326 : }
327 :
328 : export interface FileChooserShortcut {
329 : label: string;
330 : path: string;
331 : }
332 :
333 : export interface FileChooserCollection {
334 : label: string;
335 : emptyLabel: string;
336 : list: () => Promise<string[]>;
337 : }
338 :
339 : export interface FileChooserProps {
340 : title: string;
341 : shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
342 : filters?: undefined | FileChooserFilter[];
343 : collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
344 : onlyDirectories?: undefined | boolean;
345 : superuser?: cockpit.SuperuserMode;
346 : recentKey?: undefined | string;
347 : actionLabel?: string;
348 : }
349 :
350 : interface FileChooserValues {
351 : path: string;
352 : collection: null | FileChooserCollection;
353 : files: null | FileError | FileInfo[];
354 : selected: null | FileInfo;
355 : textFilter: string;
356 : filters: FileChooserFilter[];
357 : filter: FileChooserFilter;
358 : recent_collection: FileChooserCollection;
359 : shortcuts: FileChooserShortcut[];
360 : collections: FileChooserCollection[];
361 : showHidden: boolean;
362 : }
363 :
364 1 : export const FileChooser = ({
365 1 : title,
366 1 : shortcuts = [],
367 1 : filters = [],
368 1 : collections = [],
369 1 : onlyDirectories = false,
370 1 : superuser,
371 1 : recentKey = "recent-files",
372 1 : actionLabel,
373 1 : path = "",
374 1 : action,
375 1 : } : {
376 : path?: string,
377 : action: (path: string) => Promise<void>,
378 1 : } & FileChooserProps) => {
379 1 : const Dialogs = useDialogs();
380 1 : const textInputRef = useRef<HTMLInputElement>(null);
381 1 : const fsInfoClientRef = useRef<FsInfoClient | null>(null);
382 :
383 1 : function focusFilter() {
384 1 : textInputRef.current?.focus();
385 1 : }
386 :
387 1 : useEffect(() => {
388 0 : textInputRef.current?.focus();
389 1 : }, []);
390 :
391 1 : async function init(): Promise<FileChooserValues> {
392 1 : const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
393 :
394 1 : const recent_collection = {
395 1 : label: _("Recent"),
396 1 : emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
397 1 : list: async () => readRecent(recentKey)
398 1 : };
399 :
400 0 : const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
401 0 : const collections_list = Array.isArray(collections) ? collections : await collections();
402 :
403 0 : return {
404 0 : path,
405 0 : collection: path == "" ? recent_collection : null,
406 1 : files: null,
407 1 : selected: null,
408 1 : textFilter: "",
409 1 : filters: all_filters,
410 1 : filter: all_filters[0],
411 1 : recent_collection,
412 1 : shortcuts: await stdShortcuts(shortcuts_list),
413 1 : collections: collections_list,
414 1 : showHidden: false,
415 1 : };
416 1 : }
417 :
418 1 : const dlg = useDialogState_async(init);
419 :
420 1 : const setPath = useCallback(
421 1 : (dlg: DialogState<FileChooserValues>, path: string) => {
422 1 : dlg.field("path").set(path);
423 1 : dlg.field("collection").set(null);
424 1 : dlg.field("selected").set(null);
425 1 : dlg.field("files").set(null);
426 :
427 1 : if (fsInfoClientRef.current)
428 1 : fsInfoClientRef.current.close();
429 :
430 1 : fsInfoClientRef.current = watchFiles(
431 1 : path,
432 1 : onlyDirectories,
433 1 : superuser,
434 1 : files => {
435 1 : dlg.field("files").set(files);
436 1 : }
437 1 : );
438 1 : },
439 1 : [onlyDirectories, superuser],
440 1 : );
441 :
442 1 : const setCollection = useCallback(
443 1 : (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
444 1 : dlg.field("path").set("");
445 1 : dlg.field("collection").set(collection);
446 1 : dlg.field("selected").set(null);
447 1 : dlg.field("files").set(null);
448 :
449 1 : if (fsInfoClientRef.current)
450 1 : fsInfoClientRef.current.close();
451 :
452 1 : fsInfoClientRef.current = null;
453 1 : dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
454 1 : },
455 1 : [onlyDirectories, superuser],
456 1 : );
457 :
458 1 : useEffect(() => {
459 1 : if (dlg instanceof DialogState) {
460 1 : if (dlg.values.collection)
461 1 : setCollection(dlg, dlg.values.collection);
462 : else
463 1 : setPath(dlg, dlg.values.path);
464 1 : }
465 1 : return () => {
466 1 : if (fsInfoClientRef.current)
467 1 : fsInfoClientRef.current.close();
468 1 : };
469 1 : }, [dlg, setPath, setCollection]);
470 :
471 1 : function full_path(path: string, selected: string) {
472 1 : if (path == "")
473 1 : return selected;
474 : else
475 1 : return path_join(path, selected);
476 1 : }
477 :
478 1 : function selected_path(): string | null {
479 1 : if (!(dlg instanceof DialogState))
480 1 : return null;
481 :
482 1 : const { selected, path } = dlg.values;
483 :
484 1 : if (onlyDirectories) {
485 1 : if (!selected && path != "")
486 1 : return path;
487 1 : else if (selected && selected.type == "dir")
488 1 : return full_path(path, selected.name);
489 1 : } else {
490 1 : if (selected && selected.type != "dir")
491 1 : return full_path(path, selected.name);
492 1 : }
493 :
494 1 : return null;
495 1 : }
496 :
497 1 : async function onAction() {
498 1 : const full = selected_path();
499 1 : cockpit.assert(full);
500 1 : rememberRecent(full, recentKey);
501 1 : await action(full);
502 1 : }
503 :
504 1 : function breadcrumbs(dlg: DialogState<FileChooserValues>) {
505 1 : const { path } = dlg.values;
506 :
507 1 : if (path == "") {
508 : // Collection
509 1 : return null;
510 1 : } else {
511 1 : const dirs = ["/"].concat(path.split("/").filter(d => !!d));
512 1 : const crumbs: React.ReactNode[] = [];
513 1 : let full = "/";
514 1 : dirs.forEach((d, i) => {
515 1 : if (d != "/")
516 1 : full = path_join(full, d);
517 1 : const path = full;
518 1 : crumbs.push(
519 1 : <BreadcrumbItem
520 1 : key={i}
521 1 : to="#"
522 1 : onClick={
523 1 : (event) => {
524 1 : setPath(dlg, path);
525 1 : event.preventDefault();
526 1 : }
527 : }
528 1 : isActive={i == dirs.length - 1}
529 : >
530 1 : { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
531 1 : </BreadcrumbItem>
532 1 : );
533 1 : });
534 :
535 1 : return (
536 1 : <Breadcrumb>
537 1 : {crumbs}
538 1 : </Breadcrumb>
539 : );
540 1 : }
541 1 : }
542 :
543 1 : function header(dlg: DialogState<FileChooserValues>) {
544 1 : const preparedFilters = (
545 1 : dlg.values.filters.length > 1 &&
546 1 : <ToggleGroup>
547 : {
548 1 : dlg.values.filters.map(f => {
549 1 : return (
550 1 : <ToggleGroupItem
551 1 : key={f.label}
552 1 : isSelected={f == dlg.values.filter}
553 1 : onChange={() => {
554 1 : dlg.field("filter").set(f);
555 1 : focusFilter();
556 1 : }}
557 1 : text={f.label}
558 1 : />
559 : );
560 1 : })
561 : }
562 1 : </ToggleGroup>
563 : );
564 :
565 1 : const textFilter = (
566 1 : <TextInput
567 1 : ref={textInputRef}
568 1 : placeholder={_("Type to filter")}
569 1 : value={dlg.values.textFilter}
570 1 : onChange={(_event, value) => dlg.field("textFilter").set(value)}
571 1 : />
572 : );
573 :
574 1 : function shortcut(sc: FileChooserShortcut) {
575 1 : return (
576 1 : <DropdownItem
577 1 : key={sc.label}
578 0 : onClick={() => setPath(dlg, sc.path)}
579 1 : className="file-chooser-hide-on-wide"
580 : >
581 1 : {sc.label}
582 1 : </DropdownItem>
583 : );
584 1 : }
585 :
586 1 : function collection(cl: FileChooserCollection) {
587 1 : return (
588 1 : <DropdownItem
589 1 : key={cl.label}
590 0 : onClick={() => setCollection(dlg, cl)}
591 1 : className="file-chooser-hide-on-wide"
592 : >
593 1 : {cl.label}
594 1 : </DropdownItem>
595 : );
596 1 : }
597 :
598 1 : return (
599 1 : <Flex>
600 1 : <FlexItem>
601 1 : {textFilter}
602 1 : </FlexItem>
603 1 : <FlexItem>
604 1 : {preparedFilters}
605 1 : </FlexItem>
606 1 : <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
607 1 : <KebabDropdown
608 1 : dropdownItems={
609 1 : [
610 1 : <DropdownItem
611 1 : key="jump"
612 1 : onClick={
613 0 : () => {
614 0 : cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
615 0 : }
616 : }
617 1 : isDisabled={dlg.values.path === ""}
618 : >
619 1 : {_("Open in file browser")}
620 1 : </DropdownItem>,
621 1 : <DropdownItem
622 1 : key="showhide"
623 1 : onClick={
624 1 : () => {
625 1 : dlg.field("showHidden").set(!dlg.values.showHidden);
626 1 : }
627 : }
628 : >
629 1 : {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
630 1 : </DropdownItem>,
631 1 : <Divider key="divider" className="file-chooser-hide-on-wide" />,
632 1 : collection(dlg.values.recent_collection),
633 1 : ...dlg.values.shortcuts.map(shortcut),
634 1 : shortcut({ label: _("Filesystem"), path: "/" }),
635 1 : ...dlg.values.collections.map(collection)
636 1 : ]
637 : }
638 1 : />
639 1 : </FlexItem>
640 1 : </Flex>
641 : );
642 1 : }
643 :
644 1 : function formatIcon(f: FileInfo): React.ReactNode {
645 1 : if (f.type == "dir")
646 1 : return <FolderIcon />;
647 : else
648 1 : return <OutlineFileIcon />;
649 1 : }
650 :
651 1 : function sidebar(dlg: DialogState<FileChooserValues>) {
652 1 : function shortcut(sc: FileChooserShortcut) {
653 1 : return (
654 1 : <Tr
655 1 : key={sc.label}
656 1 : isClickable
657 1 : isSelectable
658 1 : isRowSelected={dlg.values.path == sc.path}
659 1 : onRowClick={
660 1 : () => {
661 1 : setPath(dlg, sc.path);
662 1 : focusFilter();
663 1 : }
664 : }
665 : >
666 1 : <Td>{sc.label}</Td>
667 1 : </Tr>
668 : );
669 1 : }
670 :
671 1 : function collection(col: FileChooserCollection) {
672 1 : return (
673 1 : <Tr
674 1 : key={col.label}
675 1 : isClickable
676 1 : isSelectable
677 1 : isRowSelected={dlg.values.collection == col}
678 1 : onRowClick={
679 1 : () => {
680 1 : setCollection(dlg, col);
681 1 : focusFilter();
682 1 : }
683 : }
684 : >
685 1 : <Td>{col.label}</Td>
686 1 : </Tr>
687 : );
688 1 : }
689 :
690 1 : return (
691 1 : <Table variant="compact" borders={false}>
692 1 : <Tbody>
693 1 : { collection(dlg.values.recent_collection) }
694 1 : { dlg.values.shortcuts.map(shortcut) }
695 1 : { shortcut({ label: _("Filesystem"), path: "/" }) }
696 1 : { dlg.values.collections.map(collection) }
697 1 : </Tbody>
698 1 : </Table>
699 : );
700 1 : }
701 :
702 1 : function listing(dlg: DialogState<FileChooserValues>) {
703 1 : function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
704 1 : return (
705 1 : <Tbody>
706 1 : <Tr>
707 1 : <Td>
708 1 : <Bullseye>
709 1 : <EmptyState
710 1 : titleText={content}
711 1 : icon={icon}
712 : >
713 1 : { (clearFilters > 0) &&
714 1 : <EmptyStateActions>
715 1 : <Button
716 1 : variant="link"
717 1 : onClick={() => {
718 1 : if (clearFilters == 3) {
719 1 : dlg.field("showHidden").set(true);
720 1 : } else {
721 1 : dlg.field("textFilter").set("");
722 1 : if (clearFilters > 1)
723 1 : dlg.field("filter")
724 1 : .set(dlg.values.filters[dlg.values.filters.length - 1]);
725 1 : }
726 1 : focusFilter();
727 1 : }}
728 : >
729 1 : {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
730 1 : </Button>
731 1 : </EmptyStateActions>
732 : }
733 1 : </EmptyState>
734 1 : </Bullseye>
735 1 : </Td>
736 1 : </Tr>
737 1 : </Tbody>
738 : );
739 1 : }
740 :
741 1 : function listingBody() {
742 1 : const files = dlg.values.files;
743 :
744 1 : if (files == null)
745 1 : return emptyState("", Spinner);
746 :
747 1 : if (files instanceof FileError)
748 1 : return emptyState(files.message, FolderIcon);
749 :
750 1 : if (files.length == 0) {
751 1 : if (dlg.values.collection) {
752 1 : return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
753 1 : } else if (!onlyDirectories) {
754 1 : return emptyState(_("Directory is empty"), FolderIcon);
755 0 : } else {
756 0 : return emptyState(_("Directory has no sub-directories"), FolderIcon);
757 0 : }
758 1 : }
759 :
760 1 : const withoutHidden = dlg.values.showHidden ? files : files.filter(f => basename(f.name)[0] !== ".");
761 1 : if (withoutHidden.length == 0)
762 1 : return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
763 :
764 1 : const preFiltered = withoutHidden.filter(
765 1 : f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(basename(f.name), f.type)
766 1 : );
767 1 : if (preFiltered.length == 0)
768 1 : return emptyState(_("No matching results"), SearchIcon, 2);
769 :
770 1 : const filtered = preFiltered.filter(f => basename(f.name).includes(dlg.values.textFilter));
771 1 : if (filtered.length == 0)
772 1 : return emptyState(_("No matching results"), SearchIcon, 1);
773 :
774 1 : return (
775 1 : <Tbody>
776 : {
777 1 : filtered.map(
778 1 : (f, idx) => {
779 1 : let name, location;
780 1 : if (dlg.values.path == "") {
781 1 : name = basename(f.name);
782 1 : location = dirname(f.name);
783 1 : } else {
784 1 : name = f.name;
785 1 : }
786 1 : return (
787 1 : <Tr
788 1 : className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
789 1 : key={idx}
790 1 : data-name={name}
791 1 : onRowClick={
792 1 : () => {
793 1 : dlg.field("selected").set(f);
794 1 : focusFilter();
795 1 : }
796 : }
797 1 : onDoubleClick={
798 1 : event => {
799 1 : event.preventDefault();
800 1 : if (f.type == "dir") {
801 1 : setPath(dlg, full_path(dlg.values.path, f.name));
802 1 : dlg.field("textFilter").set("");
803 1 : }
804 1 : focusFilter();
805 1 : }
806 : }
807 1 : isClickable
808 : >
809 1 : <Td>
810 1 : {formatIcon(f)}
811 :
812 1 : {boldify(name, dlg.values.textFilter)}
813 1 : </Td>
814 1 : { location && <Td>{location}</Td> }
815 1 : </Tr>
816 : );
817 1 : }
818 1 : )
819 : }
820 1 : </Tbody>
821 : );
822 1 : }
823 :
824 1 : return (
825 1 : <Table variant="compact" borders={false}>
826 1 : { listingBody() }
827 1 : </Table>
828 : );
829 1 : }
830 :
831 1 : return (
832 1 : <Modal
833 1 : isOpen
834 1 : variant="large"
835 1 : position="top"
836 1 : onClose={Dialogs.close}
837 1 : className="file-chooser"
838 : >
839 1 : <ModalHeader title={title} />
840 1 : <ModalBody>
841 1 : <DialogErrorMessage dialog={dlg} />
842 1 : <div className="file-chooser-body">
843 1 : <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
844 : {
845 1 : dlg instanceof DialogState
846 1 : ? sidebar(dlg)
847 1 : : <Bullseye><Spinner /></Bullseye>
848 : }
849 1 : </div>
850 1 : <div className="file-chooser-listing-header">
851 1 : { dlg instanceof DialogState && header(dlg) }
852 1 : </div>
853 1 : <div className="file-chooser-listing-breadcrumbs">
854 1 : { dlg instanceof DialogState && breadcrumbs(dlg) }
855 1 : </div>
856 1 : <div className="file-chooser-listing-body">
857 1 : { dlg instanceof DialogState && listing(dlg) }
858 1 : </div>
859 1 : </div>
860 1 : </ModalBody>
861 1 : <ModalFooter>
862 1 : <DialogActionButton
863 1 : dialog={dlg}
864 1 : isDisabled={selected_path() === null}
865 1 : action={onAction}
866 1 : onClose={Dialogs.close}
867 : >
868 1 : {actionLabel || _("Select")}
869 1 : </DialogActionButton>
870 1 : </ModalFooter>
871 1 : </Modal>
872 : );
873 1 : };
874 :
875 2 : const FileChooserButton = ({
876 2 : value,
877 2 : onChoose,
878 2 : props,
879 2 : } : {
880 : value: string,
881 : onChoose: (path: string) => void,
882 : props: FileChooserProps,
883 2 : }) => {
884 2 : const Dialogs = useDialogs();
885 :
886 2 : return (
887 2 : <Button
888 2 : variant="plain"
889 2 : icon={<FolderOpenIcon />}
890 2 : onClick={
891 1 : () => {
892 1 : Dialogs.show(
893 1 : <FileChooser
894 1 : path={value[0] == "/" ? (props.onlyDirectories ? value : dirname(value)) : ""}
895 1 : action={async path => onChoose(path)}
896 1 : {...props}
897 1 : />
898 1 : );
899 1 : }
900 : }
901 2 : />
902 : );
903 2 : };
904 :
905 2 : export const FileChooserInput = ({
906 2 : ouiaId,
907 2 : placeholder = "",
908 2 : value,
909 2 : onChange,
910 2 : isDisabled = false,
911 2 : fileChooserProps,
912 2 : } : {
913 : ouiaId?: undefined | string;
914 : placeholder?: string,
915 : value: string,
916 : onChange: (path: string, from_dialog: boolean) => void,
917 : isDisabled?: boolean,
918 : fileChooserProps: FileChooserProps,
919 2 : }) => {
920 2 : return (
921 2 : <TextInputGroup
922 2 : isDisabled={isDisabled}
923 2 : data-ouia-component-id={ouiaId}
924 : >
925 2 : <TextInputGroupMain
926 2 : value={value}
927 2 : placeholder={placeholder}
928 1 : onChange={(_event, value) => onChange(value, false)}
929 2 : autoComplete="off"
930 2 : />
931 2 : <TextInputGroupUtilities>
932 2 : <WithDialogs>
933 2 : <FileChooserButton
934 2 : value={value}
935 1 : onChoose={value => onChange(value, true)}
936 2 : props={fileChooserProps}
937 2 : />
938 2 : </WithDialogs>
939 2 : </TextInputGroupUtilities>
940 2 : </TextInputGroup>
941 : );
942 2 : };
943 :
944 2 : export const DialogFileChooserInput = ({
945 2 : field,
946 2 : label,
947 2 : placeholder = "",
948 2 : explanation,
949 2 : warning,
950 2 : excuse,
951 2 : fileChooserProps,
952 2 : } : {
953 : field: DialogField<string>,
954 : label: string,
955 : placeholder?: string,
956 : explanation?: React.ReactNode,
957 : warning?: React.ReactNode,
958 : excuse?: string | null | undefined | false,
959 : fileChooserProps: FileChooserProps,
960 2 : }) => {
961 2 : return (
962 2 : <OptionalFormGroup
963 2 : label={label}
964 : >
965 2 : <FileChooserInput
966 2 : ouiaId={field.ouia_id()}
967 2 : placeholder={placeholder}
968 2 : value={field.get()}
969 1 : onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
970 2 : isDisabled={!!excuse}
971 2 : fileChooserProps={fileChooserProps}
972 2 : />
973 2 : <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
974 2 : </OptionalFormGroup>
975 : );
976 2 : };
977 :
978 1 : export function rememberRecent(name: string, recentKey: string = "recent-files") {
979 1 : const recent = readRecent(recentKey).filter(r => r != name);
980 1 : recent.unshift(name);
981 1 : window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
982 1 : }
|