LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - FileChooser.tsx Coverage Total Hit
Test: cockpit Lines: 95.3 % 598 570
Test Date: 2026-06-25 11:17:56

            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            1 :             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            1 :         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            1 :         if (onlyDirectories) {
     295            1 :             if (!selected && path != "")
     296            1 :                 return path;
     297            1 :             else if (selected && selected.type == "dir")
     298            1 :                 return full_path(path, selected.name);
     299            1 :         } 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            1 :                     } else {
     591            1 :                         return emptyState(_("No recent directories"), FolderIcon);
     592            1 :                     }
     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              :                                             &nbsp;&nbsp;
     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 : }
        

Generated by: LCOV version 2.0-1