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