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