LCOV - code coverage report
Current view: top level - pkg/lib/cockpit - file-chooser.tsx Coverage Total Hit
Test: cockpit Lines: 97.3 % 489 476
Test Date: 2026-06-17 06:28:00

            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              :                                             &nbsp;&nbsp;
     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 : }
        

Generated by: LCOV version 2.0-1