LCOV - code coverage report
Current view: top level - pkg/lib/cockpit/react - FileChooser.tsx Coverage Total Hit
Test: cockpit Lines: 93.9 % 639 600
Test Date: 2026-07-17 14:32:59

            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 file exports two components
       7              : 
       8              :    - a FileChooser component that can be used with "Dialogs.show" to
       9              :      show a configurable, general purpose file chooser dialog
      10              : 
      11              :    - a DialogFileChooserInput component that can be used with
      12              :      "useDialogState" etc as a text input field for pathnames in
      13              :      dialogs.
      14              : 
      15              :    A FileChooser is configured via these properties:
      16              : 
      17              :    - title: string
      18              : 
      19              :    The title in the header of the dialog.
      20              : 
      21              :    - filters?: undefined | FileChooserFilter[];
      22              : 
      23              :    A list of "prepared filters".  A filter looks like this:
      24              : 
      25              :      interface FileChooserFilter {
      26              :        label: string;
      27              :        filter: (name: string, type: string) => boolean,
      28              :      }
      29              : 
      30              :    The "filter" function will be called with the base name of a file
      31              :    and its type.  The type is the string returned by "fsinfo", such as
      32              :    "reg", "dir", "blk", etc.
      33              : 
      34              :    - shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>)
      35              : 
      36              :    A list of additional shortcuts to display in the sidebar of the
      37              :    dialog.  A shortcut looks like this:
      38              : 
      39              :      interface FileChooserShortcut {
      40              :        label: string;
      41              :        path: string;
      42              :      }
      43              : 
      44              :    The path should point to a existing directory.
      45              : 
      46              :    Instead of a array of shortcuts, you can also pass a async function
      47              :    that will return the array.  The function will be called each time
      48              :    when the dialog is opened.
      49              : 
      50              :    - collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
      51              : 
      52              :    A list of additional collections. A collection is a list of files
      53              :    that are not necessarily in the same directory.  The "Recent" entry
      54              :    in the sidebar is a collection, for example.  A collection looks like this:
      55              : 
      56              :      interface FileChooserCollection {
      57              :        label: string;
      58              :        emptyLabel: string;
      59              :        list: () => Promise<string[]>;
      60              :      }
      61              : 
      62              :     The "list" function should return absolute pathnames. The
      63              :     FileChooser will query their actual types and filter out any entry
      64              :     that does not actually exist.  The files will not be further
      65              :     re-ordered before displaying them. If you want them to be sorted,
      66              :     you need to do that before returning the array.
      67              : 
      68              :    - onlyDirectories?: undefined | boolean;
      69              : 
      70              :    If true, show only directories and let the user select a
      71              :    directory.  If false, directories are of course shown, but they
      72              :    can't be selected.
      73              : 
      74              :    - superuser?: cockpit.SuperuserMode;
      75              : 
      76              :    The "superuser" option to use when listing files, etc.
      77              : 
      78              :    - recentKey?: undefined | string;
      79              : 
      80              :    A key for localStorage to retrieve the list of recent files.
      81              :    Defaults to "recent-files".
      82              : 
      83              :    - actionLabel?: string;
      84              : 
      85              :    The label to put into the apply button of the file chooser.
      86              :    Defaults to "Select".
      87              : 
      88              :    If you use the FileChooser by itself (and not via
      89              :    DialogFileChooserInput), you can also specify the following
      90              :    properties:
      91              : 
      92              :    - path: string;
      93              : 
      94              :    The initial path to open at.
      95              : 
      96              :    - action: (path: string) => Promise<void>
      97              : 
      98              :    A function to run when the user clicks the apply button.  When this
      99              :    function throws an exception, the dialog does not close and the
     100              :    error is shown in the dialog itself.
     101              : 
     102              :    The DialogFileChooserInput has the same properties as a
     103              :    DialogTextInput plus this:
     104              : 
     105              :    - fileChooserProps
     106              : 
     107              :    The properties to use when opening the FileChooser dialog, such as
     108              :    "title", "shortcuts", etc.
     109              : 
     110              :  */
     111              : 
     112            2 : import cockpit from "cockpit";
     113            2 : import React, { useRef, useCallback, useEffect } from "react";
     114              : 
     115              : import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
     116              : import { Table, Tbody, Tr, Td } from '@patternfly/react-table';
     117              : import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
     118              : import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
     119              : import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
     120              : import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
     121              : import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
     122              : import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons';
     123              : import {
     124              :     TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
     125              : } from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
     126              : import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
     127              : import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
     128              : import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
     129              : import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
     130              : import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
     131              : 
     132              : import { KebabDropdown } from "cockpit-components-dropdown";
     133              : 
     134              : import { useDialogs, WithDialogs } from 'dialogs';
     135              : import { FsInfoClient, fsinfo } from "cockpit/fsinfo";
     136              : import { basename, dirname } from "cockpit-path";
     137              : 
     138              : import {
     139              :     useDialogState_async,
     140              :     DialogState,
     141              :     DialogField,
     142              :     DialogErrorMessage,
     143              :     DialogHelperText,
     144              :     OptionalFormGroup,
     145              :     DialogActionButton,
     146              : } from 'cockpit/dialog';
     147              : 
     148              : import "./FileChooser.css";
     149              : 
     150            2 : const _ = cockpit.gettext;
     151              : 
     152            1 : async function getHomeDir(): Promise<string> {
     153            1 :     return (await cockpit.user()).home;
     154            1 : }
     155              : 
     156            1 : async function getDownloadDir(): Promise<string | null> {
     157            1 :     try {
     158            1 :         return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
     159            0 :     } catch (ex) {
     160            0 :         console.warn("Can't determine downloads directory", String(ex));
     161            0 :         return null;
     162            0 :     }
     163            1 : }
     164              : 
     165            1 : async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise<FileChooserShortcut[]> {
     166            1 :     const home = await getHomeDir();
     167            1 :     const dd = await getDownloadDir();
     168              : 
     169            1 :     return [
     170            1 :         { label: _("Home"), path: home },
     171            0 :         ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
     172            1 :         ...shortcuts,
     173            1 :     ];
     174            1 : }
     175              : 
     176            1 : const OutlineFileIcon = () => {
     177            1 :     return (
     178            1 :         <svg
     179            1 :             height="1em"
     180            1 :             width="1em"
     181            1 :             xmlns="http://www.w3.org/2000/svg"
     182            1 :             viewBox="0 0 1536 1792"
     183            1 :             fill="currentColor"
     184              :         >
     185            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" />
     186            1 :         </svg>
     187              :     );
     188            1 : };
     189              : 
     190            1 : function path_join(dir: string, base: string) {
     191            1 :     return (dir == "/" ? "" : dir) + "/" + base;
     192            1 : }
     193              : 
     194              : interface FileInfo {
     195              :     type: string;
     196              :     name: string;
     197              : }
     198              : 
     199            2 : class FileError {
     200              :     message: string;
     201              : 
     202            1 :     constructor(message: string) {
     203            1 :         this.message = message;
     204            1 :     }
     205            2 : }
     206              : 
     207            1 : function watchFiles(
     208            1 :     path: string,
     209            1 :     onlyDirectories: boolean,
     210            1 :     superuser: cockpit.SuperuserMode,
     211            1 :     callback: (files: FileError | FileInfo[]) => void,
     212            1 : ): FsInfoClient {
     213            1 :     const client = new FsInfoClient(
     214            1 :         path,
     215            1 :         ["type", "entries", "target", "targets"],
     216            1 :         {
     217            1 :             follow: true,
     218            0 :             ...(superuser ? { superuser } : { })
     219            1 :         }
     220            1 :     );
     221              : 
     222            1 :     client.on("close", message => {
     223            0 :         if ("message" in message && typeof message.message == "string")
     224            0 :             callback(new FileError(message.message));
     225            1 :     });
     226              : 
     227            1 :     client.on("change", state => {
     228            1 :         if (state.error) {
     229            1 :             callback(new FileError(state.error.message));
     230            1 :             return;
     231            1 :         }
     232              : 
     233            1 :         if (!state.info)
     234            1 :             return;
     235              : 
     236            1 :         const info = state.info;
     237              : 
     238            0 :         if (!(info.type && info.entries && info.targets)) {
     239            0 :             callback(new FileError(_("Permission denied")));
     240            0 :             return;
     241            0 :         }
     242              : 
     243            0 :         if (info.type != "dir") {
     244            0 :             callback(new FileError(_("Not a directory")));
     245            0 :             return;
     246            0 :         }
     247              : 
     248            1 :         const result: FileInfo[] = [];
     249            1 :         for (const name in info.entries) {
     250            1 :             let entry = info.entries[name];
     251            1 :             if (entry.type == "lnk" && entry.target)
     252            1 :                 entry = info.entries[entry.target] || info.targets[entry.target];
     253              : 
     254            1 :             if (entry && entry.type) {
     255            1 :                 if (!onlyDirectories || entry.type == "dir")
     256            1 :                     result.push({ type: entry.type, name });
     257            1 :             }
     258            1 :         }
     259              : 
     260            1 :         function orderType(t: string) {
     261            1 :             if (t == "dir")
     262            1 :                 return "a";
     263              :             else
     264            1 :                 return "b";
     265            1 :         }
     266              : 
     267            1 :         result.sort((a, b) => (orderType(a.type) + a.name).localeCompare(orderType(b.type) + b.name));
     268            1 :         callback(result);
     269            1 :     });
     270              : 
     271            1 :     return client;
     272            1 : }
     273              : 
     274            1 : async function getFileInfos(
     275            1 :     paths: string[],
     276            1 :     onlyDirectories: boolean,
     277            1 :     superuser: cockpit.SuperuserMode,
     278            1 : ): Promise<FileInfo[]> {
     279            1 :     const res: FileInfo[] = [];
     280              : 
     281            1 :     for (const p of paths) {
     282            1 :         try {
     283            0 :             const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
     284            1 :             if (info.type && (!onlyDirectories || info.type == "dir"))
     285            1 :                 res.push({ name: p, type: info.type });
     286            0 :         } catch (ex) {
     287            0 :             if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found"))
     288            0 :                 console.error("Failed to get file type:", p);
     289            0 :         }
     290            1 :     }
     291              : 
     292            1 :     return res;
     293            1 : }
     294              : 
     295            1 : function readRecent(recentKey: string): string[] {
     296            1 :     try {
     297            1 :         const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
     298            1 :         if (Array.isArray(value))
     299            1 :             return value.filter(r => typeof r == "string");
     300            0 :     } catch (ex) {
     301            0 :         console.warn("Failed to parse recent files", String(ex));
     302            0 :     }
     303              : 
     304            0 :     return [];
     305            1 : }
     306              : 
     307            1 : function boldify(name: string, filterText: string): React.ReactNode {
     308            1 :     if (!filterText)
     309            1 :         return name;
     310            1 :     const parts: React.ReactNode[] = [];
     311            1 :     let pos;
     312            1 :     let key = 0;
     313            1 :     while ((pos = name.indexOf(filterText)) >= 0) {
     314            1 :         parts.push(name.substring(0, pos));
     315            1 :         parts.push(<u key={key++}>{name.substring(pos, pos + filterText.length)}</u>);
     316            1 :         name = name.substring(pos + filterText.length);
     317            1 :     }
     318            1 :     if (name)
     319            1 :         parts.push(name);
     320            1 :     return parts;
     321            1 : }
     322              : 
     323              : export interface FileChooserFilter {
     324              :     label: string;
     325              :     filter: (name: string, type: string) => boolean,
     326              : }
     327              : 
     328              : export interface FileChooserShortcut {
     329              :     label: string;
     330              :     path: string;
     331              : }
     332              : 
     333              : export interface FileChooserCollection {
     334              :     label: string;
     335              :     emptyLabel: string;
     336              :     list: () => Promise<string[]>;
     337              : }
     338              : 
     339              : export interface FileChooserProps {
     340              :     title: string;
     341              :     shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
     342              :     filters?: undefined | FileChooserFilter[];
     343              :     collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
     344              :     onlyDirectories?: undefined | boolean;
     345              :     superuser?: cockpit.SuperuserMode;
     346              :     recentKey?: undefined | string;
     347              :     actionLabel?: string;
     348              : }
     349              : 
     350              : interface FileChooserValues {
     351              :     path: string;
     352              :     collection: null | FileChooserCollection;
     353              :     files: null | FileError | FileInfo[];
     354              :     selected: null | FileInfo;
     355              :     textFilter: string;
     356              :     filters: FileChooserFilter[];
     357              :     filter: FileChooserFilter;
     358              :     recent_collection: FileChooserCollection;
     359              :     shortcuts: FileChooserShortcut[];
     360              :     collections: FileChooserCollection[];
     361              :     showHidden: boolean;
     362              : }
     363              : 
     364            1 : export const FileChooser = ({
     365            1 :     title,
     366            1 :     shortcuts = [],
     367            1 :     filters = [],
     368            1 :     collections = [],
     369            1 :     onlyDirectories = false,
     370            1 :     superuser,
     371            1 :     recentKey = "recent-files",
     372            1 :     actionLabel,
     373            1 :     path = "",
     374            1 :     action,
     375            1 : } : {
     376              :     path?: string,
     377              :     action: (path: string) => Promise<void>,
     378            1 : } & FileChooserProps) => {
     379            1 :     const Dialogs = useDialogs();
     380            1 :     const textInputRef = useRef<HTMLInputElement>(null);
     381            1 :     const fsInfoClientRef = useRef<FsInfoClient | null>(null);
     382              : 
     383            1 :     function focusFilter() {
     384            1 :         textInputRef.current?.focus();
     385            1 :     }
     386              : 
     387            1 :     useEffect(() => {
     388            0 :         textInputRef.current?.focus();
     389            1 :     }, []);
     390              : 
     391            1 :     async function init(): Promise<FileChooserValues> {
     392            1 :         const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
     393              : 
     394            1 :         const recent_collection = {
     395            1 :             label: _("Recent"),
     396            1 :             emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
     397            1 :             list: async () => readRecent(recentKey)
     398            1 :         };
     399              : 
     400            0 :         const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
     401            0 :         const collections_list = Array.isArray(collections) ? collections : await collections();
     402              : 
     403            0 :         return {
     404            0 :             path,
     405            0 :             collection: path == "" ? recent_collection : null,
     406            1 :             files: null,
     407            1 :             selected: null,
     408            1 :             textFilter: "",
     409            1 :             filters: all_filters,
     410            1 :             filter: all_filters[0],
     411            1 :             recent_collection,
     412            1 :             shortcuts: await stdShortcuts(shortcuts_list),
     413            1 :             collections: collections_list,
     414            1 :             showHidden: false,
     415            1 :         };
     416            1 :     }
     417              : 
     418            1 :     const dlg = useDialogState_async(init);
     419              : 
     420            1 :     const setPath = useCallback(
     421            1 :         (dlg: DialogState<FileChooserValues>, path: string) => {
     422            1 :             dlg.field("path").set(path);
     423            1 :             dlg.field("collection").set(null);
     424            1 :             dlg.field("selected").set(null);
     425            1 :             dlg.field("files").set(null);
     426              : 
     427            1 :             if (fsInfoClientRef.current)
     428            1 :                 fsInfoClientRef.current.close();
     429              : 
     430            1 :             fsInfoClientRef.current = watchFiles(
     431            1 :                 path,
     432            1 :                 onlyDirectories,
     433            1 :                 superuser,
     434            1 :                 files => {
     435            1 :                     dlg.field("files").set(files);
     436            1 :                 }
     437            1 :             );
     438            1 :         },
     439            1 :         [onlyDirectories, superuser],
     440            1 :     );
     441              : 
     442            1 :     const setCollection = useCallback(
     443            1 :         (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
     444            1 :             dlg.field("path").set("");
     445            1 :             dlg.field("collection").set(collection);
     446            1 :             dlg.field("selected").set(null);
     447            1 :             dlg.field("files").set(null);
     448              : 
     449            1 :             if (fsInfoClientRef.current)
     450            1 :                 fsInfoClientRef.current.close();
     451              : 
     452            1 :             fsInfoClientRef.current = null;
     453            1 :             dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
     454            1 :         },
     455            1 :         [onlyDirectories, superuser],
     456            1 :     );
     457              : 
     458            1 :     useEffect(() => {
     459            1 :         if (dlg instanceof DialogState) {
     460            1 :             if (dlg.values.collection)
     461            1 :                 setCollection(dlg, dlg.values.collection);
     462              :             else
     463            1 :                 setPath(dlg, dlg.values.path);
     464            1 :         }
     465            1 :         return () => {
     466            1 :             if (fsInfoClientRef.current)
     467            1 :                 fsInfoClientRef.current.close();
     468            1 :         };
     469            1 :     }, [dlg, setPath, setCollection]);
     470              : 
     471            1 :     function full_path(path: string, selected: string) {
     472            1 :         if (path == "")
     473            1 :             return selected;
     474              :         else
     475            1 :             return path_join(path, selected);
     476            1 :     }
     477              : 
     478            1 :     function selected_path(): string | null {
     479            1 :         if (!(dlg instanceof DialogState))
     480            1 :             return null;
     481              : 
     482            1 :         const { selected, path } = dlg.values;
     483              : 
     484            1 :         if (onlyDirectories) {
     485            1 :             if (!selected && path != "")
     486            1 :                 return path;
     487            1 :             else if (selected && selected.type == "dir")
     488            1 :                 return full_path(path, selected.name);
     489            1 :         } else {
     490            1 :             if (selected && selected.type != "dir")
     491            1 :                 return full_path(path, selected.name);
     492            1 :         }
     493              : 
     494            1 :         return null;
     495            1 :     }
     496              : 
     497            1 :     async function onAction() {
     498            1 :         const full = selected_path();
     499            1 :         cockpit.assert(full);
     500            1 :         rememberRecent(full, recentKey);
     501            1 :         await action(full);
     502            1 :     }
     503              : 
     504            1 :     function breadcrumbs(dlg: DialogState<FileChooserValues>) {
     505            1 :         const { path } = dlg.values;
     506              : 
     507            1 :         if (path == "") {
     508              :             // Collection
     509            1 :             return null;
     510            1 :         } else {
     511            1 :             const dirs = ["/"].concat(path.split("/").filter(d => !!d));
     512            1 :             const crumbs: React.ReactNode[] = [];
     513            1 :             let full = "/";
     514            1 :             dirs.forEach((d, i) => {
     515            1 :                 if (d != "/")
     516            1 :                     full = path_join(full, d);
     517            1 :                 const path = full;
     518            1 :                 crumbs.push(
     519            1 :                     <BreadcrumbItem
     520            1 :                         key={i}
     521            1 :                         to="#"
     522            1 :                         onClick={
     523            1 :                             (event) => {
     524            1 :                                 setPath(dlg, path);
     525            1 :                                 event.preventDefault();
     526            1 :                             }
     527              :                         }
     528            1 :                         isActive={i == dirs.length - 1}
     529              :                     >
     530            1 :                         { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
     531            1 :                     </BreadcrumbItem>
     532            1 :                 );
     533            1 :             });
     534              : 
     535            1 :             return (
     536            1 :                 <Breadcrumb>
     537            1 :                     {crumbs}
     538            1 :                 </Breadcrumb>
     539              :             );
     540            1 :         }
     541            1 :     }
     542              : 
     543            1 :     function header(dlg: DialogState<FileChooserValues>) {
     544            1 :         const preparedFilters = (
     545            1 :             dlg.values.filters.length > 1 &&
     546            1 :                 <ToggleGroup>
     547              :                     {
     548            1 :                         dlg.values.filters.map(f => {
     549            1 :                             return (
     550            1 :                                 <ToggleGroupItem
     551            1 :                                     key={f.label}
     552            1 :                                     isSelected={f == dlg.values.filter}
     553            1 :                                     onChange={() => {
     554            1 :                                         dlg.field("filter").set(f);
     555            1 :                                         focusFilter();
     556            1 :                                     }}
     557            1 :                                     text={f.label}
     558            1 :                                 />
     559              :                             );
     560            1 :                         })
     561              :                     }
     562            1 :                 </ToggleGroup>
     563              :         );
     564              : 
     565            1 :         const textFilter = (
     566            1 :             <TextInput
     567            1 :                 ref={textInputRef}
     568            1 :                 placeholder={_("Type to filter")}
     569            1 :                 value={dlg.values.textFilter}
     570            1 :                 onChange={(_event, value) => dlg.field("textFilter").set(value)}
     571            1 :             />
     572              :         );
     573              : 
     574            1 :         function shortcut(sc: FileChooserShortcut) {
     575            1 :             return (
     576            1 :                 <DropdownItem
     577            1 :                     key={sc.label}
     578            0 :                     onClick={() => setPath(dlg, sc.path)}
     579            1 :                     className="file-chooser-hide-on-wide"
     580              :                 >
     581            1 :                     {sc.label}
     582            1 :                 </DropdownItem>
     583              :             );
     584            1 :         }
     585              : 
     586            1 :         function collection(cl: FileChooserCollection) {
     587            1 :             return (
     588            1 :                 <DropdownItem
     589            1 :                     key={cl.label}
     590            0 :                     onClick={() => setCollection(dlg, cl)}
     591            1 :                     className="file-chooser-hide-on-wide"
     592              :                 >
     593            1 :                     {cl.label}
     594            1 :                 </DropdownItem>
     595              :             );
     596            1 :         }
     597              : 
     598            1 :         return (
     599            1 :             <Flex>
     600            1 :                 <FlexItem>
     601            1 :                     {textFilter}
     602            1 :                 </FlexItem>
     603            1 :                 <FlexItem>
     604            1 :                     {preparedFilters}
     605            1 :                 </FlexItem>
     606            1 :                 <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
     607            1 :                     <KebabDropdown
     608            1 :                         dropdownItems={
     609            1 :                             [
     610            1 :                                 <DropdownItem
     611            1 :                                     key="jump"
     612            1 :                                     onClick={
     613            0 :                                         () => {
     614            0 :                                             cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
     615            0 :                                         }
     616              :                                     }
     617            1 :                                     isDisabled={dlg.values.path === ""}
     618              :                                 >
     619            1 :                                     {_("Open in file browser")}
     620            1 :                                 </DropdownItem>,
     621            1 :                                 <DropdownItem
     622            1 :                                     key="showhide"
     623            1 :                                     onClick={
     624            1 :                                         () => {
     625            1 :                                             dlg.field("showHidden").set(!dlg.values.showHidden);
     626            1 :                                         }
     627              :                                     }
     628              :                                 >
     629            1 :                                     {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
     630            1 :                                 </DropdownItem>,
     631            1 :                                 <Divider key="divider" className="file-chooser-hide-on-wide" />,
     632            1 :                                 collection(dlg.values.recent_collection),
     633            1 :                                 ...dlg.values.shortcuts.map(shortcut),
     634            1 :                                 shortcut({ label: _("Filesystem"), path: "/" }),
     635            1 :                                 ...dlg.values.collections.map(collection)
     636            1 :                             ]
     637              :                         }
     638            1 :                     />
     639            1 :                 </FlexItem>
     640            1 :             </Flex>
     641              :         );
     642            1 :     }
     643              : 
     644            1 :     function formatIcon(f: FileInfo): React.ReactNode {
     645            1 :         if (f.type == "dir")
     646            1 :             return <FolderIcon />;
     647              :         else
     648            1 :             return <OutlineFileIcon />;
     649            1 :     }
     650              : 
     651            1 :     function sidebar(dlg: DialogState<FileChooserValues>) {
     652            1 :         function shortcut(sc: FileChooserShortcut) {
     653            1 :             return (
     654            1 :                 <Tr
     655            1 :                     key={sc.label}
     656            1 :                     isClickable
     657            1 :                     isSelectable
     658            1 :                     isRowSelected={dlg.values.path == sc.path}
     659            1 :                     onRowClick={
     660            1 :                         () => {
     661            1 :                             setPath(dlg, sc.path);
     662            1 :                             focusFilter();
     663            1 :                         }
     664              :                     }
     665              :                 >
     666            1 :                     <Td>{sc.label}</Td>
     667            1 :                 </Tr>
     668              :             );
     669            1 :         }
     670              : 
     671            1 :         function collection(col: FileChooserCollection) {
     672            1 :             return (
     673            1 :                 <Tr
     674            1 :                     key={col.label}
     675            1 :                     isClickable
     676            1 :                     isSelectable
     677            1 :                     isRowSelected={dlg.values.collection == col}
     678            1 :                     onRowClick={
     679            1 :                         () => {
     680            1 :                             setCollection(dlg, col);
     681            1 :                             focusFilter();
     682            1 :                         }
     683              :                     }
     684              :                 >
     685            1 :                     <Td>{col.label}</Td>
     686            1 :                 </Tr>
     687              :             );
     688            1 :         }
     689              : 
     690            1 :         return (
     691            1 :             <Table variant="compact" borders={false}>
     692            1 :                 <Tbody>
     693            1 :                     { collection(dlg.values.recent_collection) }
     694            1 :                     { dlg.values.shortcuts.map(shortcut) }
     695            1 :                     { shortcut({ label: _("Filesystem"), path: "/" }) }
     696            1 :                     { dlg.values.collections.map(collection) }
     697            1 :                 </Tbody>
     698            1 :             </Table>
     699              :         );
     700            1 :     }
     701              : 
     702            1 :     function listing(dlg: DialogState<FileChooserValues>) {
     703            1 :         function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
     704            1 :             return (
     705            1 :                 <Tbody>
     706            1 :                     <Tr>
     707            1 :                         <Td>
     708            1 :                             <Bullseye>
     709            1 :                                 <EmptyState
     710            1 :                                     titleText={content}
     711            1 :                                     icon={icon}
     712              :                                 >
     713            1 :                                     { (clearFilters > 0) &&
     714            1 :                                         <EmptyStateActions>
     715            1 :                                             <Button
     716            1 :                                                 variant="link"
     717            1 :                                                 onClick={() => {
     718            1 :                                                     if (clearFilters == 3) {
     719            1 :                                                         dlg.field("showHidden").set(true);
     720            1 :                                                     } else {
     721            1 :                                                         dlg.field("textFilter").set("");
     722            1 :                                                         if (clearFilters > 1)
     723            1 :                                                             dlg.field("filter")
     724            1 :                                                                     .set(dlg.values.filters[dlg.values.filters.length - 1]);
     725            1 :                                                     }
     726            1 :                                                     focusFilter();
     727            1 :                                                 }}
     728              :                                             >
     729            1 :                                                 {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
     730            1 :                                             </Button>
     731            1 :                                         </EmptyStateActions>
     732              :                                     }
     733            1 :                                 </EmptyState>
     734            1 :                             </Bullseye>
     735            1 :                         </Td>
     736            1 :                     </Tr>
     737            1 :                 </Tbody>
     738              :             );
     739            1 :         }
     740              : 
     741            1 :         function listingBody() {
     742            1 :             const files = dlg.values.files;
     743              : 
     744            1 :             if (files == null)
     745            1 :                 return emptyState("", Spinner);
     746              : 
     747            1 :             if (files instanceof FileError)
     748            1 :                 return emptyState(files.message, FolderIcon);
     749              : 
     750            1 :             if (files.length == 0) {
     751            1 :                 if (dlg.values.collection) {
     752            1 :                     return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
     753            1 :                 } else if (!onlyDirectories) {
     754            1 :                     return emptyState(_("Directory is empty"), FolderIcon);
     755            0 :                 } else {
     756            0 :                     return emptyState(_("Directory has no sub-directories"), FolderIcon);
     757            0 :                 }
     758            1 :             }
     759              : 
     760            1 :             const withoutHidden = dlg.values.showHidden ? files : files.filter(f => basename(f.name)[0] !== ".");
     761            1 :             if (withoutHidden.length == 0)
     762            1 :                 return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
     763              : 
     764            1 :             const preFiltered = withoutHidden.filter(
     765            1 :                 f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(basename(f.name), f.type)
     766            1 :             );
     767            1 :             if (preFiltered.length == 0)
     768            1 :                 return emptyState(_("No matching results"), SearchIcon, 2);
     769              : 
     770            1 :             const filtered = preFiltered.filter(f => basename(f.name).includes(dlg.values.textFilter));
     771            1 :             if (filtered.length == 0)
     772            1 :                 return emptyState(_("No matching results"), SearchIcon, 1);
     773              : 
     774            1 :             return (
     775            1 :                 <Tbody>
     776              :                     {
     777            1 :                         filtered.map(
     778            1 :                             (f, idx) => {
     779            1 :                                 let name, location;
     780            1 :                                 if (dlg.values.path == "") {
     781            1 :                                     name = basename(f.name);
     782            1 :                                     location = dirname(f.name);
     783            1 :                                 } else {
     784            1 :                                     name = f.name;
     785            1 :                                 }
     786            1 :                                 return (
     787            1 :                                     <Tr
     788            1 :                                         className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
     789            1 :                                         key={idx}
     790            1 :                                         data-name={name}
     791            1 :                                         onRowClick={
     792            1 :                                             () => {
     793            1 :                                                 dlg.field("selected").set(f);
     794            1 :                                                 focusFilter();
     795            1 :                                             }
     796              :                                         }
     797            1 :                                         onDoubleClick={
     798            1 :                                             event => {
     799            1 :                                                 event.preventDefault();
     800            1 :                                                 if (f.type == "dir") {
     801            1 :                                                     setPath(dlg, full_path(dlg.values.path, f.name));
     802            1 :                                                     dlg.field("textFilter").set("");
     803            1 :                                                 }
     804            1 :                                                 focusFilter();
     805            1 :                                             }
     806              :                                         }
     807            1 :                                         isClickable
     808              :                                     >
     809            1 :                                         <Td>
     810            1 :                                             {formatIcon(f)}
     811              :                                             &nbsp;&nbsp;
     812            1 :                                             {boldify(name, dlg.values.textFilter)}
     813            1 :                                         </Td>
     814            1 :                                         { location && <Td>{location}</Td> }
     815            1 :                                     </Tr>
     816              :                                 );
     817            1 :                             }
     818            1 :                         )
     819              :                     }
     820            1 :                 </Tbody>
     821              :             );
     822            1 :         }
     823              : 
     824            1 :         return (
     825            1 :             <Table variant="compact" borders={false}>
     826            1 :                 { listingBody() }
     827            1 :             </Table>
     828              :         );
     829            1 :     }
     830              : 
     831            1 :     return (
     832            1 :         <Modal
     833            1 :             isOpen
     834            1 :             variant="large"
     835            1 :             position="top"
     836            1 :             onClose={Dialogs.close}
     837            1 :             className="file-chooser"
     838              :         >
     839            1 :             <ModalHeader title={title} />
     840            1 :             <ModalBody>
     841            1 :                 <DialogErrorMessage dialog={dlg} />
     842            1 :                 <div className="file-chooser-body">
     843            1 :                     <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
     844              :                         {
     845            1 :                             dlg instanceof DialogState
     846            1 :                                 ? sidebar(dlg)
     847            1 :                                 : <Bullseye><Spinner /></Bullseye>
     848              :                         }
     849            1 :                     </div>
     850            1 :                     <div className="file-chooser-listing-header">
     851            1 :                         { dlg instanceof DialogState && header(dlg) }
     852            1 :                     </div>
     853            1 :                     <div className="file-chooser-listing-breadcrumbs">
     854            1 :                         { dlg instanceof DialogState && breadcrumbs(dlg) }
     855            1 :                     </div>
     856            1 :                     <div className="file-chooser-listing-body">
     857            1 :                         { dlg instanceof DialogState && listing(dlg) }
     858            1 :                     </div>
     859            1 :                 </div>
     860            1 :             </ModalBody>
     861            1 :             <ModalFooter>
     862            1 :                 <DialogActionButton
     863            1 :                     dialog={dlg}
     864            1 :                     isDisabled={selected_path() === null}
     865            1 :                     action={onAction}
     866            1 :                     onClose={Dialogs.close}
     867              :                 >
     868            1 :                     {actionLabel || _("Select")}
     869            1 :                 </DialogActionButton>
     870            1 :             </ModalFooter>
     871            1 :         </Modal>
     872              :     );
     873            1 : };
     874              : 
     875            2 : const FileChooserButton = ({
     876            2 :     value,
     877            2 :     onChoose,
     878            2 :     props,
     879            2 : } : {
     880              :     value: string,
     881              :     onChoose: (path: string) => void,
     882              :     props: FileChooserProps,
     883            2 : }) => {
     884            2 :     const Dialogs = useDialogs();
     885              : 
     886            2 :     return (
     887            2 :         <Button
     888            2 :             variant="plain"
     889            2 :             icon={<FolderOpenIcon />}
     890            2 :             onClick={
     891            1 :                 () => {
     892            1 :                     Dialogs.show(
     893            1 :                         <FileChooser
     894            1 :                             path={value[0] == "/" ? (props.onlyDirectories ? value : dirname(value)) : ""}
     895            1 :                             action={async path => onChoose(path)}
     896            1 :                             {...props}
     897            1 :                         />
     898            1 :                     );
     899            1 :                 }
     900              :             }
     901            2 :         />
     902              :     );
     903            2 : };
     904              : 
     905            2 : export const FileChooserInput = ({
     906            2 :     ouiaId,
     907            2 :     placeholder = "",
     908            2 :     value,
     909            2 :     onChange,
     910            2 :     isDisabled = false,
     911            2 :     fileChooserProps,
     912            2 : } : {
     913              :     ouiaId?: undefined | string;
     914              :     placeholder?: string,
     915              :     value: string,
     916              :     onChange: (path: string, from_dialog: boolean) => void,
     917              :     isDisabled?: boolean,
     918              :     fileChooserProps: FileChooserProps,
     919            2 : }) => {
     920            2 :     return (
     921            2 :         <TextInputGroup
     922            2 :             isDisabled={isDisabled}
     923            2 :             data-ouia-component-id={ouiaId}
     924              :         >
     925            2 :             <TextInputGroupMain
     926            2 :                 value={value}
     927            2 :                 placeholder={placeholder}
     928            1 :                 onChange={(_event, value) => onChange(value, false)}
     929            2 :                 autoComplete="off"
     930            2 :             />
     931            2 :             <TextInputGroupUtilities>
     932            2 :                 <WithDialogs>
     933            2 :                     <FileChooserButton
     934            2 :                         value={value}
     935            1 :                         onChoose={value => onChange(value, true)}
     936            2 :                         props={fileChooserProps}
     937            2 :                     />
     938            2 :                 </WithDialogs>
     939            2 :             </TextInputGroupUtilities>
     940            2 :         </TextInputGroup>
     941              :     );
     942            2 : };
     943              : 
     944            2 : export const DialogFileChooserInput = ({
     945            2 :     field,
     946            2 :     label,
     947            2 :     placeholder = "",
     948            2 :     explanation,
     949            2 :     warning,
     950            2 :     excuse,
     951            2 :     fileChooserProps,
     952            2 : } : {
     953              :     field: DialogField<string>,
     954              :     label: string,
     955              :     placeholder?: string,
     956              :     explanation?: React.ReactNode,
     957              :     warning?: React.ReactNode,
     958              :     excuse?: string | null | undefined | false,
     959              :     fileChooserProps: FileChooserProps,
     960            2 : }) => {
     961            2 :     return (
     962            2 :         <OptionalFormGroup
     963            2 :             label={label}
     964              :         >
     965            2 :             <FileChooserInput
     966            2 :                 ouiaId={field.ouia_id()}
     967            2 :                 placeholder={placeholder}
     968            2 :                 value={field.get()}
     969            1 :                 onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
     970            2 :                 isDisabled={!!excuse}
     971            2 :                 fileChooserProps={fileChooserProps}
     972            2 :             />
     973            2 :             <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
     974            2 :         </OptionalFormGroup>
     975              :     );
     976            2 : };
     977              : 
     978            1 : export function rememberRecent(name: string, recentKey: string = "recent-files") {
     979            1 :     const recent = readRecent(recentKey).filter(r => r != name);
     980            1 :     recent.unshift(name);
     981            1 :     window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
     982            1 : }
        

Generated by: LCOV version 2.0-1