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, { useState, useRef, 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 1 : useEffect(() => {
410 1 : if (dlg instanceof DialogState) {
411 1 : if (dlg.values.collection)
412 1 : setCollection(dlg, dlg.values.collection);
413 : else
414 1 : setPath(dlg, dlg.values.path)
415 1 : }
416 1 : }, [dlg]);
417 :
418 1 : function setPath(dlg: DialogState<FileChooserValues>, path: string) {
419 1 : dlg.field("path").set(path);
420 1 : dlg.field("collection").set(null);
421 1 : dlg.field("selected").set(null);
422 1 : dlg.field("files").set(null);
423 :
424 1 : if (fsInfoClientRef.current)
425 1 : fsInfoClientRef.current.close();
426 :
427 1 : fsInfoClientRef.current = watchFiles(
428 1 : path,
429 1 : onlyDirectories,
430 1 : superuser,
431 1 : files => {
432 1 : dlg.field("files").set(files);
433 1 : }
434 1 : );
435 1 : }
436 :
437 1 : function setCollection(dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) {
438 1 : dlg.field("path").set("");
439 1 : dlg.field("collection").set(collection);
440 1 : dlg.field("selected").set(null);
441 1 : dlg.field("files").set(null);
442 :
443 1 : if (fsInfoClientRef.current)
444 1 : fsInfoClientRef.current.close();
445 :
446 1 : fsInfoClientRef.current = null;
447 1 : dlg.field("files").set_async(0, async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
448 1 : }
449 :
450 1 : function full_path(path: string, selected: string) {
451 1 : if (path == "")
452 1 : return selected;
453 : else
454 1 : return path_join(path, selected);
455 1 : }
456 :
457 1 : function selected_path(): string | null {
458 1 : if (!(dlg instanceof DialogState))
459 1 : return null;
460 :
461 1 : const { selected, path } = dlg.values;
462 :
463 1 : if (onlyDirectories) {
464 1 : if (!selected && path != "")
465 1 : return path;
466 1 : else if (selected && selected.type == "dir")
467 1 : return full_path(path, selected.name);
468 1 : } else {
469 1 : if (selected && selected.type != "dir")
470 1 : return full_path(path, selected.name);
471 1 : }
472 :
473 1 : return null;
474 1 : }
475 :
476 1 : async function onAction() {
477 1 : const full = selected_path();
478 1 : cockpit.assert(full);
479 1 : rememberRecent(full, recentKey);
480 1 : await action(full);
481 1 : }
482 :
483 1 : function breadcrumbs(dlg: DialogState<FileChooserValues>) {
484 1 : const { path } = dlg.values;
485 :
486 1 : if (path == "") {
487 : // Collection
488 1 : return null;
489 1 : } else {
490 1 : const dirs = ["/"].concat(path.split("/").filter(d => !!d));
491 1 : const crumbs: React.ReactNode[] = [];
492 1 : let full = "/";
493 1 : dirs.forEach((d, i) => {
494 1 : if (d != "/")
495 1 : full = path_join(full, d);
496 1 : const path = full;
497 1 : crumbs.push(
498 1 : <BreadcrumbItem
499 1 : key={i}
500 1 : to="#"
501 1 : onClick={
502 1 : (event) => {
503 1 : setPath(dlg, path);
504 1 : event.preventDefault();
505 1 : }
506 : }
507 1 : isActive={i == dirs.length - 1}
508 : >
509 1 : { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
510 1 : </BreadcrumbItem>
511 1 : );
512 1 : });
513 :
514 1 : if (crumbs.length > 0) {
515 1 : return (
516 1 : <Breadcrumb>
517 1 : {crumbs}
518 1 : </Breadcrumb>
519 : );
520 1 : }
521 1 : }
522 1 : }
523 :
524 1 : function header(dlg: DialogState<FileChooserValues>) {
525 1 : const preparedFilters = (
526 1 : dlg.values.filters.length > 1 &&
527 1 : <ToggleGroup>
528 : {
529 1 : dlg.values.filters.map(f => {
530 1 : return (
531 1 : <ToggleGroupItem
532 1 : key={f.label}
533 1 : isSelected={f == dlg.values.filter}
534 1 : onChange={() => {
535 1 : dlg.field("filter").set(f);
536 1 : focusFilter();
537 1 : }}
538 1 : text={f.label}
539 1 : />
540 : );
541 1 : })
542 : }
543 1 : </ToggleGroup>
544 : );
545 :
546 1 : const textFilter = (
547 1 : <TextInput
548 1 : ref={textInputRef}
549 1 : placeholder={_("Type to filter")}
550 1 : value={dlg.values.textFilter}
551 1 : onChange={(_event, value) => dlg.field("textFilter").set(value)}
552 1 : />
553 : );
554 :
555 1 : function shortcut(sc: FileChooserShortcut) {
556 1 : return (
557 1 : <DropdownItem
558 1 : key={sc.label}
559 1 : onClick={() => setPath(dlg, sc.path)}
560 1 : className="file-chooser-hide-on-wide"
561 : >
562 1 : {sc.label}
563 1 : </DropdownItem>
564 : );
565 1 : }
566 :
567 1 : function collection(cl: FileChooserCollection) {
568 1 : return (
569 1 : <DropdownItem
570 1 : key={cl.label}
571 0 : onClick={() => setCollection(dlg, cl)}
572 1 : className="file-chooser-hide-on-wide"
573 : >
574 1 : {cl.label}
575 1 : </DropdownItem>
576 : );
577 1 : }
578 :
579 1 : return (
580 1 : <Flex>
581 1 : <FlexItem>
582 1 : {textFilter}
583 1 : </FlexItem>
584 1 : <FlexItem>
585 1 : {preparedFilters}
586 1 : </FlexItem>
587 1 : <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
588 1 : <KebabDropdown
589 1 : dropdownItems={
590 1 : [
591 1 : <DropdownItem
592 1 : key="jump"
593 1 : onClick={
594 0 : () => {
595 0 : cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
596 0 : }
597 : }
598 1 : isDisabled={dlg.values.path === ""}
599 : >
600 1 : {_("Open in file browser")}
601 1 : </DropdownItem>,
602 1 : <DropdownItem
603 1 : key="showhide"
604 1 : onClick={
605 1 : () => {
606 1 : dlg.field("showHidden").set(!dlg.values.showHidden);
607 1 : }
608 : }
609 : >
610 1 : {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
611 1 : </DropdownItem>,
612 1 : <Divider key="divider" className="file-chooser-hide-on-wide"/>,
613 1 : collection(dlg.values.recent_collection),
614 1 : ...dlg.values.shortcuts.map(shortcut),
615 1 : shortcut({ label: _("Filesystem"), path: "/" }),
616 1 : ...dlg.values.collections.map(collection)
617 1 : ]
618 : }
619 1 : />
620 1 : </FlexItem>
621 1 : </Flex>
622 : );
623 1 : }
624 :
625 1 : function formatIcon(f: FileInfo): React.ReactNode {
626 1 : if (f.type == "dir")
627 1 : return <FolderIcon />;
628 : else
629 1 : return <FileIcon />;
630 1 : }
631 :
632 1 : function sidebar(dlg: DialogState<FileChooserValues>) {
633 1 : function shortcut(sc: FileChooserShortcut) {
634 1 : return (
635 1 : <Tr
636 1 : key={sc.label}
637 1 : isClickable
638 1 : isSelectable
639 1 : isRowSelected={dlg.values.path == sc.path}
640 1 : onRowClick={
641 1 : () => {
642 1 : setPath(dlg, sc.path);
643 1 : focusFilter();
644 1 : }
645 : }
646 : >
647 1 : <Td>{sc.label}</Td>
648 1 : </Tr>
649 : );
650 1 : }
651 :
652 1 : function collection(col: FileChooserCollection) {
653 1 : return (
654 1 : <Tr
655 1 : key={col.label}
656 1 : isClickable
657 1 : isSelectable
658 1 : isRowSelected={dlg.values.collection == col}
659 1 : onRowClick={
660 1 : () => {
661 1 : setCollection(dlg, col);
662 1 : focusFilter();
663 1 : }
664 : }
665 : >
666 1 : <Td>{col.label}</Td>
667 1 : </Tr>
668 : );
669 1 : }
670 :
671 1 : return (
672 1 : <Table variant="compact" borders={false}>
673 1 : <Tbody>
674 1 : { collection(dlg.values.recent_collection) }
675 1 : { dlg.values.shortcuts.map(shortcut) }
676 1 : { shortcut({ label: _("Filesystem"), path: "/" }) }
677 1 : { dlg.values.collections.map(collection) }
678 1 : </Tbody>
679 1 : </Table>
680 : );
681 1 : }
682 :
683 1 : function listing(dlg: DialogState<FileChooserValues>) {
684 :
685 1 : function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
686 1 : return (
687 1 : <Caption>
688 1 : <EmptyState
689 1 : titleText={content}
690 1 : icon={icon}
691 : >
692 1 : { (clearFilters > 0) &&
693 1 : <EmptyStateActions>
694 1 : <Button
695 1 : variant="link"
696 1 : onClick={() => {
697 1 : if (clearFilters == 3) {
698 1 : dlg.field("showHidden").set(true);
699 1 : } else {
700 1 : dlg.field("textFilter").set("");
701 1 : if (clearFilters > 1)
702 1 : dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
703 1 : }
704 1 : focusFilter();
705 1 : }}
706 : >
707 1 : {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
708 1 : </Button>
709 1 : </EmptyStateActions>
710 : }
711 1 : </EmptyState>
712 1 : </Caption>
713 : );
714 1 : }
715 :
716 1 : function listingBody() {
717 1 : const files = dlg.values.files;
718 :
719 1 : if (files == null)
720 1 : return emptyState("", Spinner);
721 :
722 1 : if (files instanceof FileError)
723 1 : return emptyState(files.message, FolderIcon);
724 :
725 1 : if (files.length == 0) {
726 1 : if (dlg.values.collection) {
727 1 : return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
728 1 : } else if (!onlyDirectories) {
729 1 : return emptyState(_("Directory is empty"), FolderIcon);
730 0 : } else {
731 0 : return emptyState(_("Directory has no sub-directories"), FolderIcon);
732 0 : }
733 1 : }
734 :
735 1 : const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
736 1 : if (withoutHidden.length == 0)
737 1 : return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
738 :
739 1 : const preFiltered = withoutHidden.filter(
740 1 : f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
741 1 : );
742 1 : if (preFiltered.length == 0)
743 1 : return emptyState(_("No matching results"), SearchIcon, 2);
744 :
745 1 : const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
746 1 : if (filtered.length == 0)
747 1 : return emptyState(_("No matching results"), SearchIcon, 1);
748 :
749 1 : return (
750 1 : <Tbody>
751 : {
752 1 : filtered.map(
753 1 : (f, idx) => {
754 1 : let name, location;
755 1 : if (dlg.values.path == "") {
756 1 : name = basename(f.name);
757 1 : location = dirname(f.name);
758 1 : } else {
759 1 : name = f.name;
760 1 : }
761 1 : return (
762 1 : <Tr
763 1 : className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
764 1 : key={idx}
765 1 : data-name={name}
766 1 : onRowClick={
767 1 : () => {
768 1 : dlg.field("selected").set(f);
769 1 : focusFilter();
770 1 : }
771 : }
772 1 : onDoubleClick={
773 1 : event => {
774 1 : event.preventDefault();
775 1 : if (f.type == "dir")
776 1 : setPath(dlg, full_path(dlg.values.path, f.name));
777 1 : dlg.field("textFilter").set("");
778 1 : focusFilter();
779 1 : }
780 : }
781 1 : isClickable
782 : >
783 1 : <Td>
784 1 : {formatIcon(f)}
785 :
786 1 : {boldify(name, dlg.values.textFilter)}
787 1 : </Td>
788 1 : { location && <Td>{location}</Td> }
789 1 : </Tr>
790 : );
791 1 : }
792 1 : )
793 : }
794 1 : </Tbody>
795 : );
796 1 : }
797 :
798 1 : return (
799 1 : <Table variant="compact" borders={false}>
800 1 : { listingBody() }
801 1 : </Table>
802 : );
803 1 : }
804 :
805 1 : return (
806 1 : <Modal
807 1 : isOpen
808 1 : variant="large"
809 1 : position="top"
810 1 : onClose={Dialogs.close}
811 1 : className="file-chooser"
812 : >
813 1 : <ModalHeader
814 1 : title={title}
815 1 : description={<DialogErrorMessage dialog={dlg} />}
816 1 : />
817 1 : <ModalBody>
818 1 : <div className="file-chooser-body">
819 1 : <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
820 : {
821 1 : dlg instanceof DialogState
822 1 : ? sidebar(dlg)
823 1 : : <Bullseye><Spinner /></Bullseye>
824 : }
825 1 : </div>
826 1 : <div className="file-chooser-listing-header">
827 1 : { dlg instanceof DialogState && header(dlg) }
828 1 : </div>
829 1 : <div className="file-chooser-listing-breadcrumbs">
830 1 : { dlg instanceof DialogState && breadcrumbs(dlg) }
831 1 : </div>
832 1 : <div className="file-chooser-listing-body">
833 1 : { dlg instanceof DialogState && listing(dlg) }
834 1 : </div>
835 1 : </div>
836 1 : </ModalBody>
837 1 : <ModalFooter>
838 1 : <DialogActionButton
839 1 : dialog={dlg}
840 1 : isAriaDisabled={selected_path() === null}
841 1 : action={onAction}
842 1 : onClose={Dialogs.close}
843 : >
844 1 : {actionLabel || _("Select")}
845 1 : </DialogActionButton>
846 1 : </ModalFooter>
847 1 : </Modal>
848 : );
849 1 : };
850 :
851 2 : const FileChooserButton = ({
852 2 : value,
853 2 : onChoose,
854 2 : props,
855 2 : } : {
856 : value: string,
857 : onChoose: (path: string) => void,
858 : props: FileChooserProps,
859 2 : }) => {
860 2 : const Dialogs = useDialogs();
861 :
862 2 : return (
863 2 : <Button
864 2 : variant="plain"
865 2 : icon={<FolderOpenIcon />}
866 2 : onClick={
867 1 : async () => {
868 1 : Dialogs.show(
869 1 : <FileChooser
870 1 : path={value[0] == "/" ? dirname(value) : ""}
871 1 : action={async path => onChoose(path)}
872 1 : {...props}
873 1 : />
874 1 : );
875 1 : }
876 : }
877 2 : />
878 : );
879 2 : };
880 :
881 2 : export const FileChooserInput = ({
882 2 : ouiaId,
883 2 : placeholder = "",
884 2 : value,
885 2 : onChange,
886 2 : isDisabled = false,
887 2 : fileChooserProps,
888 2 : } : {
889 : ouiaId?: undefined | string;
890 : placeholder?: string,
891 : value: string,
892 : onChange: (path: string) => void,
893 : isDisabled?: boolean,
894 : fileChooserProps: FileChooserProps,
895 2 : }) => {
896 2 : return (
897 2 : <TextInputGroup
898 2 : isDisabled={isDisabled}
899 2 : data-ouia-component-id={ouiaId}
900 : >
901 2 : <TextInputGroupMain
902 2 : value={value}
903 2 : placeholder={placeholder}
904 1 : onChange={(_event, value) => onChange(value)}
905 2 : autoComplete="off"
906 2 : />
907 2 : <TextInputGroupUtilities>
908 2 : <WithDialogs>
909 2 : <FileChooserButton
910 2 : value={value}
911 2 : onChoose={onChange}
912 2 : props={fileChooserProps}
913 2 : />
914 2 : </WithDialogs>
915 2 : </TextInputGroupUtilities>
916 2 : </TextInputGroup>
917 : );
918 2 : };
919 :
920 2 : export const DialogFileChooserInput = ({
921 2 : field,
922 2 : label,
923 2 : placeholder = "",
924 2 : explanation,
925 2 : warning,
926 2 : excuse,
927 2 : fileChooserProps,
928 2 : } : {
929 : field: DialogField<string>,
930 : label: string,
931 : placeholder?: string,
932 : explanation?: React.ReactNode,
933 : warning?: React.ReactNode,
934 : excuse?: string | null | undefined | false,
935 : fileChooserProps: FileChooserProps,
936 2 : }) => {
937 2 : return (
938 2 : <OptionalFormGroup
939 2 : label={label}
940 : >
941 2 : <FileChooserInput
942 2 : ouiaId={field.ouia_id()}
943 2 : placeholder={placeholder}
944 2 : value={field.get()}
945 1 : onChange={val => field.set(val)}
946 2 : isDisabled={!!excuse}
947 2 : fileChooserProps={fileChooserProps}
948 2 : />
949 2 : <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
950 2 : </OptionalFormGroup>
951 : );
952 2 : };
953 :
954 1 : export function rememberRecent(name: string, recentKey: string = "recent-files") {
955 1 : const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
956 1 : if (Array.isArray(value)) {
957 1 : const recent = value.filter(r => typeof r == "string" && r != name);
958 1 : recent.unshift(name);
959 1 : window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
960 1 : }
961 1 : }
|