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