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