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