LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 89.0 % 745 663
Test Date: 2026-07-17 14:32:59

            Line data    Source code
       1              : diff --git a/pkg/lib/cockpit/react/FileChooser.css b/pkg/lib/cockpit/react/FileChooser.css
       2              : new file mode 100644
       3              : index 000000000..5de7dc50b
       4              : --- /dev/null
       5              : +++ b/pkg/lib/cockpit/react/FileChooser.css
       6              : @@ -0,0 +1,89 @@
       7              : +/*
       8              : + * Copyright (C) 2026 Red Hat, Inc.
       9              : + * SPDX-License-Identifier: LGPL-2.1-or-later
      10              : + */
      11              : +
      12              : +.file-chooser-body {
      13              : +    display: grid;
      14              : +    grid-template-columns: minmax(15em, auto) 1fr;
      15              : +    grid-template-rows: auto auto 1fr;
      16              : +    column-gap: var(--pf-t--global--spacer--md);
      17              : +    row-gap: var(--pf-t--global--spacer--md);
      18              : +    block-size: 60ex;
      19              : +}
      20              : +
      21              : +.file-chooser-sidebar {
      22              : +    grid-column: 1 / 2;
      23              : +    grid-row: 1 / 4;
      24              : +    overflow-y: scroll;
      25              : +    border-inline-end: solid 2px var(--pf-t--global--background--color--disabled--default);
      26              : +    padding-inline-end: var(--pf-t--global--spacer--md);
      27              : +}
      28              : +
      29              : +.file-chooser-listing-header {
      30              : +    grid-column: 2 / 3;
      31              : +    grid-row: 1 / 2;
      32              : +}
      33              : +
      34              : +.file-chooser-listing-header > div {
      35              : +    block-size: 100%;
      36              : +}
      37              : +
      38              : +.file-chooser-listing-breadcrumbs {
      39              : +    grid-column: 2 / 3;
      40              : +    grid-row: 2 / 3;
      41              : +    /* align left of breadcrumb with left of table content */
      42              : +    padding-inline-start: var(--pf-t--global--spacer--inset--page-chrome);
      43              : +}
      44              : +
      45              : +.file-chooser-listing-body {
      46              : +    grid-column: 2 / 3;
      47              : +    grid-row: 3 / 4;
      48              : +    overflow-y: scroll;
      49              : +}
      50              : +
      51              : +.file-chooser .pf-v6-c-alert {
      52              : +    margin-block-end: var(--pf-t--global--spacer--lg);
      53              : +}
      54              : +
      55              : +@media (width < 768px) {
      56              : +    .file-chooser-body {
      57              : +        grid-template-columns: 0 1fr;
      58              : +    }
      59              : +
      60              : +    .file-chooser-hide-on-narrow {
      61              : +        display: none;
      62              : +    }
      63              : +}
      64              : +
      65              : +@media (width >= 768px) {
      66              : +    .file-chooser-hide-on-wide {
      67              : +        display: none;
      68              : +    }
      69              : +}
      70              : +
      71              : +.pf-v6-c-table tr.file-chooser-selected:where(.pf-v6-c-table__tr) > :where(th, td) {
      72              : +    background: var(--pf-t--global--color--nonstatus--blue--default);
      73              : +    color: var(--pf-t--global--text--color--nonstatus--on-blue--default);
      74              : +}
      75              : +
      76              : +/* Style the breadcrumb component as a path */
      77              : +.file-chooser-listing-breadcrumbs .pf-v6-c-breadcrumb__item-divider {
      78              : +    > svg {
      79              : +        display: none;
      80              : +    }
      81              : +
      82              : +    &::after {
      83              : +        content: "/";
      84              : +    }
      85              : +}
      86              : +
      87              : +/* Size, align, and space icon correctly */
      88              : +.file-chooser-listing-breadcrumbs .breadcrumb-hdd-icon {
      89              : +    /* Set the size to a large icon */
      90              : +    block-size: var(--pf-t--global--font--size--lg);
      91              : +    /* Width should resolve itself based on height and aspect ratio */
      92              : +    inline-size: auto;
      93              : +    /* Align to the middle (as one would expect) */
      94              : +    vertical-align: middle;
      95              : +}
      96              : diff --git a/pkg/lib/cockpit/react/FileChooser.tsx b/pkg/lib/cockpit/react/FileChooser.tsx
      97              : new file mode 100644
      98              : index 000000000..97af607d2
      99              : --- /dev/null
     100              : +++ b/pkg/lib/cockpit/react/FileChooser.tsx
     101              : @@ -0,0 +1,982 @@
     102              : +/*
     103              : + * Copyright (C) 2026 Red Hat, Inc.
     104              : + * SPDX-License-Identifier: LGPL-2.1-or-later
     105              : + */
     106              : +
     107              : +/* This file exports two components
     108              : +
     109              : +   - a FileChooser component that can be used with "Dialogs.show" to
     110              : +     show a configurable, general purpose file chooser dialog
     111              : +
     112              : +   - a DialogFileChooserInput component that can be used with
     113              : +     "useDialogState" etc as a text input field for pathnames in
     114              : +     dialogs.
     115              : +
     116              : +   A FileChooser is configured via these properties:
     117              : +
     118              : +   - title: string
     119              : +
     120              : +   The title in the header of the dialog.
     121              : +
     122              : +   - filters?: undefined | FileChooserFilter[];
     123              : +
     124              : +   A list of "prepared filters".  A filter looks like this:
     125              : +
     126              : +     interface FileChooserFilter {
     127              : +       label: string;
     128              : +       filter: (name: string, type: string) => boolean,
     129              : +     }
     130              : +
     131              : +   The "filter" function will be called with the base name of a file
     132              : +   and its type.  The type is the string returned by "fsinfo", such as
     133              : +   "reg", "dir", "blk", etc.
     134              : +
     135              : +   - shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>)
     136              : +
     137              : +   A list of additional shortcuts to display in the sidebar of the
     138              : +   dialog.  A shortcut looks like this:
     139              : +
     140              : +     interface FileChooserShortcut {
     141              : +       label: string;
     142              : +       path: string;
     143              : +     }
     144              : +
     145              : +   The path should point to a existing directory.
     146              : +
     147              : +   Instead of a array of shortcuts, you can also pass a async function
     148              : +   that will return the array.  The function will be called each time
     149              : +   when the dialog is opened.
     150              : +
     151              : +   - collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
     152              : +
     153              : +   A list of additional collections. A collection is a list of files
     154              : +   that are not necessarily in the same directory.  The "Recent" entry
     155              : +   in the sidebar is a collection, for example.  A collection looks like this:
     156              : +
     157              : +     interface FileChooserCollection {
     158              : +       label: string;
     159              : +       emptyLabel: string;
     160              : +       list: () => Promise<string[]>;
     161              : +     }
     162              : +
     163              : +    The "list" function should return absolute pathnames. The
     164              : +    FileChooser will query their actual types and filter out any entry
     165              : +    that does not actually exist.  The files will not be further
     166              : +    re-ordered before displaying them. If you want them to be sorted,
     167              : +    you need to do that before returning the array.
     168              : +
     169              : +   - onlyDirectories?: undefined | boolean;
     170              : +
     171              : +   If true, show only directories and let the user select a
     172              : +   directory.  If false, directories are of course shown, but they
     173              : +   can't be selected.
     174              : +
     175              : +   - superuser?: cockpit.SuperuserMode;
     176              : +
     177              : +   The "superuser" option to use when listing files, etc.
     178              : +
     179              : +   - recentKey?: undefined | string;
     180              : +
     181              : +   A key for localStorage to retrieve the list of recent files.
     182              : +   Defaults to "recent-files".
     183              : +
     184              : +   - actionLabel?: string;
     185              : +
     186              : +   The label to put into the apply button of the file chooser.
     187              : +   Defaults to "Select".
     188              : +
     189              : +   If you use the FileChooser by itself (and not via
     190              : +   DialogFileChooserInput), you can also specify the following
     191              : +   properties:
     192              : +
     193              : +   - path: string;
     194              : +
     195              : +   The initial path to open at.
     196              : +
     197              : +   - action: (path: string) => Promise<void>
     198              : +
     199              : +   A function to run when the user clicks the apply button.  When this
     200              : +   function throws an exception, the dialog does not close and the
     201              : +   error is shown in the dialog itself.
     202              : +
     203              : +   The DialogFileChooserInput has the same properties as a
     204              : +   DialogTextInput plus this:
     205              : +
     206              : +   - fileChooserProps
     207              : +
     208              : +   The properties to use when opening the FileChooser dialog, such as
     209              : +   "title", "shortcuts", etc.
     210              : +
     211              : + */
     212              : +
     213            2 : +import cockpit from "cockpit";
     214            2 : +import React, { useRef, useCallback, useEffect } from "react";
     215              : +
     216              : +import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
     217              : +import { Table, Tbody, Tr, Td } from '@patternfly/react-table';
     218              : +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
     219              : +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
     220              : +import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
     221              : +import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
     222              : +import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
     223              : +import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons';
     224              : +import {
     225              : +    TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
     226              : +} from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
     227              : +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
     228              : +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
     229              : +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
     230              : +import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
     231              : +import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
     232              : +
     233              : +import { KebabDropdown } from "cockpit-components-dropdown";
     234              : +
     235              : +import { useDialogs, WithDialogs } from 'dialogs';
     236              : +import { FsInfoClient, fsinfo } from "cockpit/fsinfo";
     237              : +import { basename, dirname } from "cockpit-path";
     238              : +
     239              : +import {
     240              : +    useDialogState_async,
     241              : +    DialogState,
     242              : +    DialogField,
     243              : +    DialogErrorMessage,
     244              : +    DialogHelperText,
     245              : +    OptionalFormGroup,
     246              : +    DialogActionButton,
     247              : +} from 'cockpit/dialog';
     248              : +
     249              : +import "./FileChooser.css";
     250              : +
     251            2 : +const _ = cockpit.gettext;
     252              : +
     253            1 : +async function getHomeDir(): Promise<string> {
     254            1 : +    return (await cockpit.user()).home;
     255            1 : +}
     256              : +
     257            1 : +async function getDownloadDir(): Promise<string | null> {
     258            1 : +    try {
     259            1 : +        return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
     260            0 : +    } catch (ex) {
     261            0 : +        console.warn("Can't determine downloads directory", String(ex));
     262            0 : +        return null;
     263            0 : +    }
     264            1 : +}
     265              : +
     266            1 : +async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise<FileChooserShortcut[]> {
     267            1 : +    const home = await getHomeDir();
     268            1 : +    const dd = await getDownloadDir();
     269              : +
     270            1 : +    return [
     271            1 : +        { label: _("Home"), path: home },
     272            0 : +        ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
     273            1 : +        ...shortcuts,
     274            1 : +    ];
     275            1 : +}
     276              : +
     277            1 : +const OutlineFileIcon = () => {
     278            1 : +    return (
     279            1 : +        <svg
     280            1 : +            height="1em"
     281            1 : +            width="1em"
     282            1 : +            xmlns="http://www.w3.org/2000/svg"
     283            1 : +            viewBox="0 0 1536 1792"
     284            1 : +            fill="currentColor"
     285              : +        >
     286            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" />
     287            1 : +        </svg>
     288              : +    );
     289            1 : +};
     290              : +
     291            1 : +function path_join(dir: string, base: string) {
     292            1 : +    return (dir == "/" ? "" : dir) + "/" + base;
     293            1 : +}
     294              : +
     295              : +interface FileInfo {
     296              : +    type: string;
     297              : +    name: string;
     298              : +}
     299              : +
     300            2 : +class FileError {
     301              : +    message: string;
     302              : +
     303            1 : +    constructor(message: string) {
     304            1 : +        this.message = message;
     305            1 : +    }
     306            2 : +}
     307              : +
     308            1 : +function watchFiles(
     309            1 : +    path: string,
     310            1 : +    onlyDirectories: boolean,
     311            1 : +    superuser: cockpit.SuperuserMode,
     312            1 : +    callback: (files: FileError | FileInfo[]) => void,
     313            1 : +): FsInfoClient {
     314            1 : +    const client = new FsInfoClient(
     315            1 : +        path,
     316            1 : +        ["type", "entries", "target", "targets"],
     317            1 : +        {
     318            1 : +            follow: true,
     319            0 : +            ...(superuser ? { superuser } : { })
     320            1 : +        }
     321            1 : +    );
     322              : +
     323            1 : +    client.on("close", message => {
     324            0 : +        if ("message" in message && typeof message.message == "string")
     325            0 : +            callback(new FileError(message.message));
     326            1 : +    });
     327              : +
     328            1 : +    client.on("change", state => {
     329            1 : +        if (state.error) {
     330            1 : +            callback(new FileError(state.error.message));
     331            1 : +            return;
     332            1 : +        }
     333              : +
     334            1 : +        if (!state.info)
     335            1 : +            return;
     336              : +
     337            1 : +        const info = state.info;
     338              : +
     339            0 : +        if (!(info.type && info.entries && info.targets)) {
     340            0 : +            callback(new FileError(_("Permission denied")));
     341            0 : +            return;
     342            0 : +        }
     343              : +
     344            0 : +        if (info.type != "dir") {
     345            0 : +            callback(new FileError(_("Not a directory")));
     346            0 : +            return;
     347            0 : +        }
     348              : +
     349            1 : +        const result: FileInfo[] = [];
     350            1 : +        for (const name in info.entries) {
     351            1 : +            let entry = info.entries[name];
     352            1 : +            if (entry.type == "lnk" && entry.target)
     353            1 : +                entry = info.entries[entry.target] || info.targets[entry.target];
     354              : +
     355            1 : +            if (entry && entry.type) {
     356            1 : +                if (!onlyDirectories || entry.type == "dir")
     357            1 : +                    result.push({ type: entry.type, name });
     358            1 : +            }
     359            1 : +        }
     360              : +
     361            1 : +        function orderType(t: string) {
     362            1 : +            if (t == "dir")
     363            1 : +                return "a";
     364              : +            else
     365            1 : +                return "b";
     366            1 : +        }
     367              : +
     368            1 : +        result.sort((a, b) => (orderType(a.type) + a.name).localeCompare(orderType(b.type) + b.name));
     369            1 : +        callback(result);
     370            1 : +    });
     371              : +
     372            1 : +    return client;
     373            1 : +}
     374              : +
     375            1 : +async function getFileInfos(
     376            1 : +    paths: string[],
     377            1 : +    onlyDirectories: boolean,
     378            1 : +    superuser: cockpit.SuperuserMode,
     379            1 : +): Promise<FileInfo[]> {
     380            1 : +    const res: FileInfo[] = [];
     381              : +
     382            1 : +    for (const p of paths) {
     383            1 : +        try {
     384            0 : +            const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
     385            1 : +            if (info.type && (!onlyDirectories || info.type == "dir"))
     386            1 : +                res.push({ name: p, type: info.type });
     387            0 : +        } catch (ex) {
     388            0 : +            if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found"))
     389            0 : +                console.error("Failed to get file type:", p);
     390            0 : +        }
     391            1 : +    }
     392              : +
     393            1 : +    return res;
     394            1 : +}
     395              : +
     396            1 : +function readRecent(recentKey: string): string[] {
     397            1 : +    try {
     398            1 : +        const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
     399            1 : +        if (Array.isArray(value))
     400            1 : +            return value.filter(r => typeof r == "string");
     401            0 : +    } catch (ex) {
     402            0 : +        console.warn("Failed to parse recent files", String(ex));
     403            0 : +    }
     404              : +
     405            0 : +    return [];
     406            1 : +}
     407              : +
     408            1 : +function boldify(name: string, filterText: string): React.ReactNode {
     409            1 : +    if (!filterText)
     410            1 : +        return name;
     411            1 : +    const parts: React.ReactNode[] = [];
     412            1 : +    let pos;
     413            1 : +    let key = 0;
     414            1 : +    while ((pos = name.indexOf(filterText)) >= 0) {
     415            1 : +        parts.push(name.substring(0, pos));
     416            1 : +        parts.push(<u key={key++}>{name.substring(pos, pos + filterText.length)}</u>);
     417            1 : +        name = name.substring(pos + filterText.length);
     418            1 : +    }
     419            1 : +    if (name)
     420            1 : +        parts.push(name);
     421            1 : +    return parts;
     422            1 : +}
     423              : +
     424              : +export interface FileChooserFilter {
     425              : +    label: string;
     426              : +    filter: (name: string, type: string) => boolean,
     427              : +}
     428              : +
     429              : +export interface FileChooserShortcut {
     430              : +    label: string;
     431              : +    path: string;
     432              : +}
     433              : +
     434              : +export interface FileChooserCollection {
     435              : +    label: string;
     436              : +    emptyLabel: string;
     437              : +    list: () => Promise<string[]>;
     438              : +}
     439              : +
     440              : +export interface FileChooserProps {
     441              : +    title: string;
     442              : +    shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
     443              : +    filters?: undefined | FileChooserFilter[];
     444              : +    collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
     445              : +    onlyDirectories?: undefined | boolean;
     446              : +    superuser?: cockpit.SuperuserMode;
     447              : +    recentKey?: undefined | string;
     448              : +    actionLabel?: string;
     449              : +}
     450              : +
     451              : +interface FileChooserValues {
     452              : +    path: string;
     453              : +    collection: null | FileChooserCollection;
     454              : +    files: null | FileError | FileInfo[];
     455              : +    selected: null | FileInfo;
     456              : +    textFilter: string;
     457              : +    filters: FileChooserFilter[];
     458              : +    filter: FileChooserFilter;
     459              : +    recent_collection: FileChooserCollection;
     460              : +    shortcuts: FileChooserShortcut[];
     461              : +    collections: FileChooserCollection[];
     462              : +    showHidden: boolean;
     463              : +}
     464              : +
     465            1 : +export const FileChooser = ({
     466            1 : +    title,
     467            1 : +    shortcuts = [],
     468            1 : +    filters = [],
     469            1 : +    collections = [],
     470            1 : +    onlyDirectories = false,
     471            1 : +    superuser,
     472            1 : +    recentKey = "recent-files",
     473            1 : +    actionLabel,
     474            1 : +    path = "",
     475            1 : +    action,
     476            1 : +} : {
     477              : +    path?: string,
     478              : +    action: (path: string) => Promise<void>,
     479            1 : +} & FileChooserProps) => {
     480            1 : +    const Dialogs = useDialogs();
     481            1 : +    const textInputRef = useRef<HTMLInputElement>(null);
     482            1 : +    const fsInfoClientRef = useRef<FsInfoClient | null>(null);
     483              : +
     484            1 : +    function focusFilter() {
     485            1 : +        textInputRef.current?.focus();
     486            1 : +    }
     487              : +
     488            1 : +    useEffect(() => {
     489            0 : +        textInputRef.current?.focus();
     490            1 : +    }, []);
     491              : +
     492            1 : +    async function init(): Promise<FileChooserValues> {
     493            1 : +        const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
     494              : +
     495            1 : +        const recent_collection = {
     496            1 : +            label: _("Recent"),
     497            1 : +            emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
     498            1 : +            list: async () => readRecent(recentKey)
     499            1 : +        };
     500              : +
     501            0 : +        const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
     502            0 : +        const collections_list = Array.isArray(collections) ? collections : await collections();
     503              : +
     504            0 : +        return {
     505            0 : +            path,
     506            0 : +            collection: path == "" ? recent_collection : null,
     507            1 : +            files: null,
     508            1 : +            selected: null,
     509            1 : +            textFilter: "",
     510            1 : +            filters: all_filters,
     511            1 : +            filter: all_filters[0],
     512            1 : +            recent_collection,
     513            1 : +            shortcuts: await stdShortcuts(shortcuts_list),
     514            1 : +            collections: collections_list,
     515            1 : +            showHidden: false,
     516            1 : +        };
     517            1 : +    }
     518              : +
     519            1 : +    const dlg = useDialogState_async(init);
     520              : +
     521            1 : +    const setPath = useCallback(
     522            1 : +        (dlg: DialogState<FileChooserValues>, path: string) => {
     523            1 : +            dlg.field("path").set(path);
     524            1 : +            dlg.field("collection").set(null);
     525            1 : +            dlg.field("selected").set(null);
     526            1 : +            dlg.field("files").set(null);
     527              : +
     528            1 : +            if (fsInfoClientRef.current)
     529            1 : +                fsInfoClientRef.current.close();
     530              : +
     531            1 : +            fsInfoClientRef.current = watchFiles(
     532            1 : +                path,
     533            1 : +                onlyDirectories,
     534            1 : +                superuser,
     535            1 : +                files => {
     536            1 : +                    dlg.field("files").set(files);
     537            1 : +                }
     538            1 : +            );
     539            1 : +        },
     540            1 : +        [onlyDirectories, superuser],
     541            1 : +    );
     542              : +
     543            1 : +    const setCollection = useCallback(
     544            1 : +        (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
     545            1 : +            dlg.field("path").set("");
     546            1 : +            dlg.field("collection").set(collection);
     547            1 : +            dlg.field("selected").set(null);
     548            1 : +            dlg.field("files").set(null);
     549              : +
     550            1 : +            if (fsInfoClientRef.current)
     551            1 : +                fsInfoClientRef.current.close();
     552              : +
     553            1 : +            fsInfoClientRef.current = null;
     554            1 : +            dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
     555            1 : +        },
     556            1 : +        [onlyDirectories, superuser],
     557            1 : +    );
     558              : +
     559            1 : +    useEffect(() => {
     560            1 : +        if (dlg instanceof DialogState) {
     561            1 : +            if (dlg.values.collection)
     562            1 : +                setCollection(dlg, dlg.values.collection);
     563              : +            else
     564            1 : +                setPath(dlg, dlg.values.path);
     565            1 : +        }
     566            1 : +        return () => {
     567            1 : +            if (fsInfoClientRef.current)
     568            1 : +                fsInfoClientRef.current.close();
     569            1 : +        };
     570            1 : +    }, [dlg, setPath, setCollection]);
     571              : +
     572            1 : +    function full_path(path: string, selected: string) {
     573            1 : +        if (path == "")
     574            1 : +            return selected;
     575              : +        else
     576            1 : +            return path_join(path, selected);
     577            1 : +    }
     578              : +
     579            1 : +    function selected_path(): string | null {
     580            1 : +        if (!(dlg instanceof DialogState))
     581            1 : +            return null;
     582              : +
     583            1 : +        const { selected, path } = dlg.values;
     584              : +
     585            1 : +        if (onlyDirectories) {
     586            1 : +            if (!selected && path != "")
     587            1 : +                return path;
     588            1 : +            else if (selected && selected.type == "dir")
     589            1 : +                return full_path(path, selected.name);
     590            1 : +        } else {
     591            1 : +            if (selected && selected.type != "dir")
     592            1 : +                return full_path(path, selected.name);
     593            1 : +        }
     594              : +
     595            1 : +        return null;
     596            1 : +    }
     597              : +
     598            1 : +    async function onAction() {
     599            1 : +        const full = selected_path();
     600            1 : +        cockpit.assert(full);
     601            1 : +        rememberRecent(full, recentKey);
     602            1 : +        await action(full);
     603            1 : +    }
     604              : +
     605            1 : +    function breadcrumbs(dlg: DialogState<FileChooserValues>) {
     606            1 : +        const { path } = dlg.values;
     607              : +
     608            1 : +        if (path == "") {
     609              : +            // Collection
     610            1 : +            return null;
     611            1 : +        } else {
     612            1 : +            const dirs = ["/"].concat(path.split("/").filter(d => !!d));
     613            1 : +            const crumbs: React.ReactNode[] = [];
     614            1 : +            let full = "/";
     615            1 : +            dirs.forEach((d, i) => {
     616            1 : +                if (d != "/")
     617            1 : +                    full = path_join(full, d);
     618            1 : +                const path = full;
     619            1 : +                crumbs.push(
     620            1 : +                    <BreadcrumbItem
     621            1 : +                        key={i}
     622            1 : +                        to="#"
     623            1 : +                        onClick={
     624            1 : +                            (event) => {
     625            1 : +                                setPath(dlg, path);
     626            1 : +                                event.preventDefault();
     627            1 : +                            }
     628              : +                        }
     629            1 : +                        isActive={i == dirs.length - 1}
     630              : +                    >
     631            1 : +                        { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
     632            1 : +                    </BreadcrumbItem>
     633            1 : +                );
     634            1 : +            });
     635              : +
     636            1 : +            return (
     637            1 : +                <Breadcrumb>
     638            1 : +                    {crumbs}
     639            1 : +                </Breadcrumb>
     640              : +            );
     641            1 : +        }
     642            1 : +    }
     643              : +
     644            1 : +    function header(dlg: DialogState<FileChooserValues>) {
     645            1 : +        const preparedFilters = (
     646            1 : +            dlg.values.filters.length > 1 &&
     647            1 : +                <ToggleGroup>
     648              : +                    {
     649            1 : +                        dlg.values.filters.map(f => {
     650            1 : +                            return (
     651            1 : +                                <ToggleGroupItem
     652            1 : +                                    key={f.label}
     653            1 : +                                    isSelected={f == dlg.values.filter}
     654            1 : +                                    onChange={() => {
     655            1 : +                                        dlg.field("filter").set(f);
     656            1 : +                                        focusFilter();
     657            1 : +                                    }}
     658            1 : +                                    text={f.label}
     659            1 : +                                />
     660              : +                            );
     661            1 : +                        })
     662              : +                    }
     663            1 : +                </ToggleGroup>
     664              : +        );
     665              : +
     666            1 : +        const textFilter = (
     667            1 : +            <TextInput
     668            1 : +                ref={textInputRef}
     669            1 : +                placeholder={_("Type to filter")}
     670            1 : +                value={dlg.values.textFilter}
     671            1 : +                onChange={(_event, value) => dlg.field("textFilter").set(value)}
     672            1 : +            />
     673              : +        );
     674              : +
     675            1 : +        function shortcut(sc: FileChooserShortcut) {
     676            1 : +            return (
     677            1 : +                <DropdownItem
     678            1 : +                    key={sc.label}
     679            0 : +                    onClick={() => setPath(dlg, sc.path)}
     680            1 : +                    className="file-chooser-hide-on-wide"
     681              : +                >
     682            1 : +                    {sc.label}
     683            1 : +                </DropdownItem>
     684              : +            );
     685            1 : +        }
     686              : +
     687            1 : +        function collection(cl: FileChooserCollection) {
     688            1 : +            return (
     689            1 : +                <DropdownItem
     690            1 : +                    key={cl.label}
     691            0 : +                    onClick={() => setCollection(dlg, cl)}
     692            1 : +                    className="file-chooser-hide-on-wide"
     693              : +                >
     694            1 : +                    {cl.label}
     695            1 : +                </DropdownItem>
     696              : +            );
     697            1 : +        }
     698              : +
     699            1 : +        return (
     700            1 : +            <Flex>
     701            1 : +                <FlexItem>
     702            1 : +                    {textFilter}
     703            1 : +                </FlexItem>
     704            1 : +                <FlexItem>
     705            1 : +                    {preparedFilters}
     706            1 : +                </FlexItem>
     707            1 : +                <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
     708            1 : +                    <KebabDropdown
     709            1 : +                        dropdownItems={
     710            1 : +                            [
     711            1 : +                                <DropdownItem
     712            1 : +                                    key="jump"
     713            1 : +                                    onClick={
     714            0 : +                                        () => {
     715            0 : +                                            cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
     716            0 : +                                        }
     717              : +                                    }
     718            1 : +                                    isDisabled={dlg.values.path === ""}
     719              : +                                >
     720            1 : +                                    {_("Open in file browser")}
     721            1 : +                                </DropdownItem>,
     722            1 : +                                <DropdownItem
     723            1 : +                                    key="showhide"
     724            1 : +                                    onClick={
     725            1 : +                                        () => {
     726            1 : +                                            dlg.field("showHidden").set(!dlg.values.showHidden);
     727            1 : +                                        }
     728              : +                                    }
     729              : +                                >
     730            1 : +                                    {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
     731            1 : +                                </DropdownItem>,
     732            1 : +                                <Divider key="divider" className="file-chooser-hide-on-wide" />,
     733            1 : +                                collection(dlg.values.recent_collection),
     734            1 : +                                ...dlg.values.shortcuts.map(shortcut),
     735            1 : +                                shortcut({ label: _("Filesystem"), path: "/" }),
     736            1 : +                                ...dlg.values.collections.map(collection)
     737            1 : +                            ]
     738              : +                        }
     739            1 : +                    />
     740            1 : +                </FlexItem>
     741            1 : +            </Flex>
     742              : +        );
     743            1 : +    }
     744              : +
     745            1 : +    function formatIcon(f: FileInfo): React.ReactNode {
     746            1 : +        if (f.type == "dir")
     747            1 : +            return <FolderIcon />;
     748              : +        else
     749            1 : +            return <OutlineFileIcon />;
     750            1 : +    }
     751              : +
     752            1 : +    function sidebar(dlg: DialogState<FileChooserValues>) {
     753            1 : +        function shortcut(sc: FileChooserShortcut) {
     754            1 : +            return (
     755            1 : +                <Tr
     756            1 : +                    key={sc.label}
     757            1 : +                    isClickable
     758            1 : +                    isSelectable
     759            1 : +                    isRowSelected={dlg.values.path == sc.path}
     760            1 : +                    onRowClick={
     761            1 : +                        () => {
     762            1 : +                            setPath(dlg, sc.path);
     763            1 : +                            focusFilter();
     764            1 : +                        }
     765              : +                    }
     766              : +                >
     767            1 : +                    <Td>{sc.label}</Td>
     768            1 : +                </Tr>
     769              : +            );
     770            1 : +        }
     771              : +
     772            1 : +        function collection(col: FileChooserCollection) {
     773            1 : +            return (
     774            1 : +                <Tr
     775            1 : +                    key={col.label}
     776            1 : +                    isClickable
     777            1 : +                    isSelectable
     778            1 : +                    isRowSelected={dlg.values.collection == col}
     779            1 : +                    onRowClick={
     780            1 : +                        () => {
     781            1 : +                            setCollection(dlg, col);
     782            1 : +                            focusFilter();
     783            1 : +                        }
     784              : +                    }
     785              : +                >
     786            1 : +                    <Td>{col.label}</Td>
     787            1 : +                </Tr>
     788              : +            );
     789            1 : +        }
     790              : +
     791            1 : +        return (
     792            1 : +            <Table variant="compact" borders={false}>
     793            1 : +                <Tbody>
     794            1 : +                    { collection(dlg.values.recent_collection) }
     795            1 : +                    { dlg.values.shortcuts.map(shortcut) }
     796            1 : +                    { shortcut({ label: _("Filesystem"), path: "/" }) }
     797            1 : +                    { dlg.values.collections.map(collection) }
     798            1 : +                </Tbody>
     799            1 : +            </Table>
     800              : +        );
     801            1 : +    }
     802              : +
     803            1 : +    function listing(dlg: DialogState<FileChooserValues>) {
     804            1 : +        function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
     805            1 : +            return (
     806            1 : +                <Tbody>
     807            1 : +                    <Tr>
     808            1 : +                        <Td>
     809            1 : +                            <Bullseye>
     810            1 : +                                <EmptyState
     811            1 : +                                    titleText={content}
     812            1 : +                                    icon={icon}
     813              : +                                >
     814            1 : +                                    { (clearFilters > 0) &&
     815            1 : +                                        <EmptyStateActions>
     816            1 : +                                            <Button
     817            1 : +                                                variant="link"
     818            1 : +                                                onClick={() => {
     819            1 : +                                                    if (clearFilters == 3) {
     820            1 : +                                                        dlg.field("showHidden").set(true);
     821            1 : +                                                    } else {
     822            1 : +                                                        dlg.field("textFilter").set("");
     823            1 : +                                                        if (clearFilters > 1)
     824            1 : +                                                            dlg.field("filter")
     825            1 : +                                                                    .set(dlg.values.filters[dlg.values.filters.length - 1]);
     826            1 : +                                                    }
     827            1 : +                                                    focusFilter();
     828            1 : +                                                }}
     829              : +                                            >
     830            1 : +                                                {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
     831            1 : +                                            </Button>
     832            1 : +                                        </EmptyStateActions>
     833              : +                                    }
     834            1 : +                                </EmptyState>
     835            1 : +                            </Bullseye>
     836            1 : +                        </Td>
     837            1 : +                    </Tr>
     838            1 : +                </Tbody>
     839              : +            );
     840            1 : +        }
     841              : +
     842            1 : +        function listingBody() {
     843            1 : +            const files = dlg.values.files;
     844              : +
     845            1 : +            if (files == null)
     846            1 : +                return emptyState("", Spinner);
     847              : +
     848            1 : +            if (files instanceof FileError)
     849            1 : +                return emptyState(files.message, FolderIcon);
     850              : +
     851            1 : +            if (files.length == 0) {
     852            1 : +                if (dlg.values.collection) {
     853            1 : +                    return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
     854            1 : +                } else if (!onlyDirectories) {
     855            1 : +                    return emptyState(_("Directory is empty"), FolderIcon);
     856            0 : +                } else {
     857            0 : +                    return emptyState(_("Directory has no sub-directories"), FolderIcon);
     858            0 : +                }
     859            1 : +            }
     860              : +
     861            1 : +            const withoutHidden = dlg.values.showHidden ? files : files.filter(f => basename(f.name)[0] !== ".");
     862            1 : +            if (withoutHidden.length == 0)
     863            1 : +                return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
     864              : +
     865            1 : +            const preFiltered = withoutHidden.filter(
     866            1 : +                f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(basename(f.name), f.type)
     867            1 : +            );
     868            1 : +            if (preFiltered.length == 0)
     869            1 : +                return emptyState(_("No matching results"), SearchIcon, 2);
     870              : +
     871            1 : +            const filtered = preFiltered.filter(f => basename(f.name).includes(dlg.values.textFilter));
     872            1 : +            if (filtered.length == 0)
     873            1 : +                return emptyState(_("No matching results"), SearchIcon, 1);
     874              : +
     875            1 : +            return (
     876            1 : +                <Tbody>
     877              : +                    {
     878            1 : +                        filtered.map(
     879            1 : +                            (f, idx) => {
     880            1 : +                                let name, location;
     881            1 : +                                if (dlg.values.path == "") {
     882            1 : +                                    name = basename(f.name);
     883            1 : +                                    location = dirname(f.name);
     884            1 : +                                } else {
     885            1 : +                                    name = f.name;
     886            1 : +                                }
     887            1 : +                                return (
     888            1 : +                                    <Tr
     889            1 : +                                        className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
     890            1 : +                                        key={idx}
     891            1 : +                                        data-name={name}
     892            1 : +                                        onRowClick={
     893            1 : +                                            () => {
     894            1 : +                                                dlg.field("selected").set(f);
     895            1 : +                                                focusFilter();
     896            1 : +                                            }
     897              : +                                        }
     898            1 : +                                        onDoubleClick={
     899            1 : +                                            event => {
     900            1 : +                                                event.preventDefault();
     901            1 : +                                                if (f.type == "dir") {
     902            1 : +                                                    setPath(dlg, full_path(dlg.values.path, f.name));
     903            1 : +                                                    dlg.field("textFilter").set("");
     904            1 : +                                                }
     905            1 : +                                                focusFilter();
     906            1 : +                                            }
     907              : +                                        }
     908            1 : +                                        isClickable
     909              : +                                    >
     910            1 : +                                        <Td>
     911            1 : +                                            {formatIcon(f)}
     912              : +                                            &nbsp;&nbsp;
     913            1 : +                                            {boldify(name, dlg.values.textFilter)}
     914            1 : +                                        </Td>
     915            1 : +                                        { location && <Td>{location}</Td> }
     916            1 : +                                    </Tr>
     917              : +                                );
     918            1 : +                            }
     919            1 : +                        )
     920              : +                    }
     921            1 : +                </Tbody>
     922              : +            );
     923            1 : +        }
     924              : +
     925            1 : +        return (
     926            1 : +            <Table variant="compact" borders={false}>
     927            1 : +                { listingBody() }
     928            1 : +            </Table>
     929              : +        );
     930            1 : +    }
     931              : +
     932            1 : +    return (
     933            1 : +        <Modal
     934            1 : +            isOpen
     935            1 : +            variant="large"
     936            1 : +            position="top"
     937            1 : +            onClose={Dialogs.close}
     938            1 : +            className="file-chooser"
     939              : +        >
     940            1 : +            <ModalHeader title={title} />
     941            1 : +            <ModalBody>
     942            1 : +                <DialogErrorMessage dialog={dlg} />
     943            1 : +                <div className="file-chooser-body">
     944            1 : +                    <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
     945              : +                        {
     946            1 : +                            dlg instanceof DialogState
     947            1 : +                                ? sidebar(dlg)
     948            1 : +                                : <Bullseye><Spinner /></Bullseye>
     949              : +                        }
     950            1 : +                    </div>
     951            1 : +                    <div className="file-chooser-listing-header">
     952            1 : +                        { dlg instanceof DialogState && header(dlg) }
     953            1 : +                    </div>
     954            1 : +                    <div className="file-chooser-listing-breadcrumbs">
     955            1 : +                        { dlg instanceof DialogState && breadcrumbs(dlg) }
     956            1 : +                    </div>
     957            1 : +                    <div className="file-chooser-listing-body">
     958            1 : +                        { dlg instanceof DialogState && listing(dlg) }
     959            1 : +                    </div>
     960            1 : +                </div>
     961            1 : +            </ModalBody>
     962            1 : +            <ModalFooter>
     963            1 : +                <DialogActionButton
     964            1 : +                    dialog={dlg}
     965            1 : +                    isDisabled={selected_path() === null}
     966            1 : +                    action={onAction}
     967            1 : +                    onClose={Dialogs.close}
     968              : +                >
     969            1 : +                    {actionLabel || _("Select")}
     970            1 : +                </DialogActionButton>
     971            1 : +            </ModalFooter>
     972            1 : +        </Modal>
     973              : +    );
     974            1 : +};
     975              : +
     976            2 : +const FileChooserButton = ({
     977            2 : +    value,
     978            2 : +    onChoose,
     979            2 : +    props,
     980            2 : +} : {
     981              : +    value: string,
     982              : +    onChoose: (path: string) => void,
     983              : +    props: FileChooserProps,
     984            2 : +}) => {
     985            2 : +    const Dialogs = useDialogs();
     986              : +
     987            2 : +    return (
     988            2 : +        <Button
     989            2 : +            variant="plain"
     990            2 : +            icon={<FolderOpenIcon />}
     991            2 : +            onClick={
     992            1 : +                () => {
     993            1 : +                    Dialogs.show(
     994            1 : +                        <FileChooser
     995            1 : +                            path={value[0] == "/" ? (props.onlyDirectories ? value : dirname(value)) : ""}
     996            1 : +                            action={async path => onChoose(path)}
     997            1 : +                            {...props}
     998            1 : +                        />
     999            1 : +                    );
    1000            1 : +                }
    1001              : +            }
    1002            2 : +        />
    1003              : +    );
    1004            2 : +};
    1005              : +
    1006            2 : +export const FileChooserInput = ({
    1007            2 : +    ouiaId,
    1008            2 : +    placeholder = "",
    1009            2 : +    value,
    1010            2 : +    onChange,
    1011            2 : +    isDisabled = false,
    1012            2 : +    fileChooserProps,
    1013            2 : +} : {
    1014              : +    ouiaId?: undefined | string;
    1015              : +    placeholder?: string,
    1016              : +    value: string,
    1017              : +    onChange: (path: string, from_dialog: boolean) => void,
    1018              : +    isDisabled?: boolean,
    1019              : +    fileChooserProps: FileChooserProps,
    1020            2 : +}) => {
    1021            2 : +    return (
    1022            2 : +        <TextInputGroup
    1023            2 : +            isDisabled={isDisabled}
    1024            2 : +            data-ouia-component-id={ouiaId}
    1025              : +        >
    1026            2 : +            <TextInputGroupMain
    1027            2 : +                value={value}
    1028            2 : +                placeholder={placeholder}
    1029            1 : +                onChange={(_event, value) => onChange(value, false)}
    1030            2 : +                autoComplete="off"
    1031            2 : +            />
    1032            2 : +            <TextInputGroupUtilities>
    1033            2 : +                <WithDialogs>
    1034            2 : +                    <FileChooserButton
    1035            2 : +                        value={value}
    1036            1 : +                        onChoose={value => onChange(value, true)}
    1037            2 : +                        props={fileChooserProps}
    1038            2 : +                    />
    1039            2 : +                </WithDialogs>
    1040            2 : +            </TextInputGroupUtilities>
    1041            2 : +        </TextInputGroup>
    1042              : +    );
    1043            2 : +};
    1044              : +
    1045            2 : +export const DialogFileChooserInput = ({
    1046            2 : +    field,
    1047            2 : +    label,
    1048            2 : +    placeholder = "",
    1049            2 : +    explanation,
    1050            2 : +    warning,
    1051            2 : +    excuse,
    1052            2 : +    fileChooserProps,
    1053            2 : +} : {
    1054              : +    field: DialogField<string>,
    1055              : +    label: string,
    1056              : +    placeholder?: string,
    1057              : +    explanation?: React.ReactNode,
    1058              : +    warning?: React.ReactNode,
    1059              : +    excuse?: string | null | undefined | false,
    1060              : +    fileChooserProps: FileChooserProps,
    1061            2 : +}) => {
    1062            2 : +    return (
    1063            2 : +        <OptionalFormGroup
    1064            2 : +            label={label}
    1065              : +        >
    1066            2 : +            <FileChooserInput
    1067            2 : +                ouiaId={field.ouia_id()}
    1068            2 : +                placeholder={placeholder}
    1069            2 : +                value={field.get()}
    1070            1 : +                onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
    1071            2 : +                isDisabled={!!excuse}
    1072            2 : +                fileChooserProps={fileChooserProps}
    1073            2 : +            />
    1074            2 : +            <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
    1075            2 : +        </OptionalFormGroup>
    1076              : +    );
    1077            2 : +};
    1078              : +
    1079            1 : +export function rememberRecent(name: string, recentKey: string = "recent-files") {
    1080            1 : +    const recent = readRecent(recentKey).filter(r => r != name);
    1081            1 : +    recent.unshift(name);
    1082            1 : +    window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
    1083            1 : +}
    1084              : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
    1085              : index 58f890d56..3d3ee4a8d 100644
    1086              : --- a/pkg/playground/dialog.tsx
    1087              : +++ b/pkg/playground/dialog.tsx
    1088              : @@ -36,6 +36,8 @@ import {
    1089              :      DialogActionButton, DialogCancelButton,
    1090              :  } from 'cockpit/dialog';
    1091              :  
    1092              : +import { FileChooser, DialogFileChooserInput } from "cockpit/react/FileChooser";
    1093              : +
    1094              :  import 'cockpit-dark-theme'; // once per page
    1095              :  import 'page.scss';
    1096              :  
    1097              : @@ -218,6 +220,9 @@ interface ExampleValues {
    1098              :      alternative: false | string;
    1099              :      error: string;
    1100              :      allow_force: boolean;
    1101              : +    file: string;
    1102              : +    file_explanation: string;
    1103              : +    dir: string;
    1104              :  }
    1105              :  
    1106              :  const ExampleDialog = ({
    1107              : @@ -246,6 +251,9 @@ const ExampleDialog = ({
    1108              :          alternative: false,
    1109              :          error: "none",
    1110              :          allow_force: false,
    1111            2 : +        file: "",
    1112            2 : +        file_explanation: "",
    1113            2 : +        dir: "",
    1114              :      };
    1115              :  
    1116              :      function validate(dlg: DialogState<ExampleValues>) {
    1117              : @@ -270,6 +278,10 @@ const ExampleDialog = ({
    1118              :              });
    1119              :          });
    1120              :          dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
    1121            1 : +        dlg.field("file").validate(v => {
    1122            0 : +            if (v && v[0] != "/")
    1123            0 : +                return "Must be absolute";
    1124            1 : +        });
    1125              :      }
    1126              :  
    1127              :      const dlg = useDialogState(init, validate);
    1128              : @@ -317,6 +329,15 @@ const ExampleDialog = ({
    1129              :          });
    1130              :      }
    1131              :  
    1132            1 : +    function file_changed(val: string) {
    1133            1 : +        dlg.field("file_explanation").set_async(async () => {
    1134            1 : +            if (val[0] == "/")
    1135            1 : +                return cockpit.spawn(["file", "-b", val], { superuser: "try" });
    1136              : +            else
    1137            1 : +                return "--";
    1138            1 : +        });
    1139            1 : +    }
    1140              : +
    1141              :      return (
    1142              :          <Modal
    1143              :              id="dialog"
    1144              : @@ -413,6 +434,49 @@ const ExampleDialog = ({
    1145              :                          checkbox_label="Allow force"
    1146              :                          field={dlg.field("allow_force")}
    1147              :                      />
    1148            2 : +                    <DialogFileChooserInput
    1149            2 : +                        label="File"
    1150            2 : +                        field={dlg.field("file", file_changed)}
    1151            2 : +                        explanation={dlg.values.file_explanation}
    1152            2 : +                        fileChooserProps={
    1153            2 : +                            {
    1154            2 : +                                title: "Select a file",
    1155            2 : +                                superuser: "try",
    1156            2 : +                                filters: [
    1157            1 : +                                    { label: "No dots", filter: n => !n.includes(".") },
    1158            2 : +                                ],
    1159            2 : +                                shortcuts: [
    1160            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1161            2 : +                                ],
    1162            2 : +                                collections: [
    1163            2 : +                                    {
    1164            2 : +                                        label: "Some files",
    1165            2 : +                                        emptyLabel: "Nothing there",
    1166            1 : +                                        list: async () => {
    1167            1 : +                                            return [
    1168            1 : +                                                "/var/lib/cockpittest/file-chooser-test/dots.txt",
    1169            1 : +                                                "/var/lib/cockpittest/file-chooser-test/foo",
    1170            1 : +                                            ];
    1171            1 : +                                        }
    1172            2 : +                                    }
    1173            2 : +                                ]
    1174            2 : +                            }
    1175              : +                        }
    1176            2 : +                    />
    1177            2 : +                    <DialogFileChooserInput
    1178            2 : +                        label="Directory"
    1179            2 : +                        field={dlg.field("dir")}
    1180            2 : +                        fileChooserProps={
    1181            2 : +                            {
    1182            2 : +                                title: "Select a directory",
    1183            2 : +                                onlyDirectories: true,
    1184            2 : +                                superuser: "try",
    1185            2 : +                                shortcuts: [
    1186            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1187            2 : +                                ],
    1188            2 : +                            }
    1189              : +                        }
    1190            2 : +                    />
    1191              :                  </Form>
    1192              :              </ModalBody>
    1193              :              <ModalFooter>
    1194              : @@ -695,6 +759,65 @@ const SimpleExampleButtons = () => {
    1195              :      );
    1196              :  };
    1197              :  
    1198            2 : +const FileChooserButton = () => {
    1199            2 : +    const Dialogs = useDialogs();
    1200              : +
    1201            0 : +    async function loadFile(path: string) {
    1202            0 : +        const data = await cockpit.file(path).read();
    1203            0 : +        if (!data.startsWith("foo"))
    1204            0 : +            throw new Error("Does not start with \"foo\"");
    1205            0 : +    }
    1206              : +
    1207            2 : +    return (
    1208            2 : +        <Button
    1209            2 : +            id="open-file-chooser"
    1210            2 : +            onClick={
    1211            0 : +                () => Dialogs.show(
    1212            0 : +                    <FileChooser
    1213            0 : +                        title={"Select a file that starts with \"foo\""}
    1214            0 : +                        actionLabel="Load"
    1215            0 : +                        action={loadFile}
    1216            0 : +                        filters={
    1217            0 : +                            [
    1218            0 : +                                {
    1219            0 : +                                    label: "TXT files",
    1220            0 : +                                    filter: (name, type) => type == "reg" && !!name.match("\\.txt$")
    1221            0 : +                                },
    1222            0 : +                            ]
    1223              : +                        }
    1224            0 : +                        shortcuts={
    1225            0 : +                            async () => {
    1226            0 : +                                await async_sleep(500);
    1227            0 : +                                return [
    1228            0 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1229            0 : +                                ];
    1230            0 : +                            }
    1231              : +                        }
    1232            0 : +                        collections={
    1233            0 : +                            async () => {
    1234            0 : +                                return [
    1235            0 : +                                    {
    1236            0 : +                                        label: "Some TXT files",
    1237            0 : +                                        emptyLabel: "Nothing there",
    1238            0 : +                                        list: async () => {
    1239            0 : +                                            return [
    1240            0 : +                                                "/var/lib/cockpittest/file-chooser-test/text/foo.txt",
    1241            0 : +                                                "/var/lib/cockpittest/file-chooser-test/no-such-file.txt"
    1242            0 : +                                            ];
    1243            0 : +                                        }
    1244            0 : +                                    }
    1245            0 : +                                ];
    1246            0 : +                            }
    1247              : +                        }
    1248            0 : +                    />
    1249            0 : +                )
    1250              : +            }
    1251            2 : +        >
    1252              : +            Open FileChooser
    1253            2 : +        </Button>
    1254              : +    );
    1255            2 : +};
    1256              : +
    1257              :  const Demo = () => {
    1258              :      return (
    1259              :          <WithDialogs>
    1260              : @@ -702,6 +825,7 @@ const Demo = () => {
    1261              :                  <PageSection>
    1262              :                      <ExampleButton />
    1263              :                      <SimpleExampleButtons />
    1264            2 : +                    <FileChooserButton />
    1265              :                  </PageSection>
    1266              :              </Page>
    1267              :          </WithDialogs>
    1268              : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
    1269              : index d9f040327..b2f5981c7 100644
    1270              : --- a/test/common/dialoglib.py
    1271              : +++ b/test/common/dialoglib.py
    1272              : @@ -148,3 +148,14 @@ class DialogHelpers:
    1273              :  
    1274              :      def set_DropdownSelect(self, path: str, val: str) -> None:
    1275              :          self.browser.select_from_dropdown(self.field(path), val)
    1276              : +
    1277              : +    # FileChooserInput
    1278              : +
    1279              : +    def get_FileChooserInput(self, path: str) -> str:
    1280              : +        return self.browser.val(self.field(path) + " input")
    1281              : +
    1282              : +    def wait_FileChooserInput(self, path: str, val: str):
    1283              : +        self.browser.wait_val(self.field(path) + " input", val)
    1284              : +
    1285              : +    def set_FileChooserInput(self, path: str, val: str) -> None:
    1286              : +        self.browser.set_input_text(self.field(path) + " input", val)
    1287              : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
    1288              : index f614daa33..d04db10d6 100755
    1289              : --- a/test/verify/check-dialog
    1290              : +++ b/test/verify/check-dialog
    1291              : @@ -350,6 +350,283 @@ class TestDialog(testlib.MachineCase):
    1292              :          b.click(d.cancel_button())
    1293              :          b.wait_not_present("#dialog")
    1294              :  
    1295              : +    def testFileChooser(self):
    1296              : +        b = self.browser
    1297              : +        m = self.machine
    1298              : +        d = dialoglib.DialogHelpers(b, "#dialog")
    1299              : +        df = dialoglib.DialogHelpers(b, ".file-chooser")
    1300              : +
    1301              : +        # Inject a mock xdg-user-dir utility.
    1302              : +
    1303              : +        self.write_file("/usr/local/bin/xdg-user-dir",
    1304              : +"""#! /bin/sh
    1305              : +echo $HOME/Downloads
    1306              : +""", perm="a+x")
    1307              : +
    1308              : +        # Where our test files are. This is intended to be the same as
    1309              : +        # self.vm_tmpdir, but it is also hard-coded into
    1310              : +        # pkg/playground/dialog.tsx and so we hard-code it here as
    1311              : +        # well.
    1312              : +
    1313              : +        test_files = "/var/lib/cockpittest"
    1314              : +
    1315              : +        self.login_and_go("/playground/dialog", superuser=False)
    1316              : +
    1317              : +        b.click("#open")
    1318              : +
    1319              : +        # Use the get_FileChooserInput method so that Vulture doesn't
    1320              : +        # complain about it being unused.
    1321              : +
    1322              : +        self.assertEqual(d.get_FileChooserInput("file"), "")
    1323              : +
    1324              : +        # The first open has a empty Recent tab.
    1325              : +
    1326              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1327              : +        b.wait_in_text(".file-chooser-listing-body", "No recent files")
    1328              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1329              : +        b.wait_not_present(".file-chooser")
    1330              : +
    1331              : +        # Basic interaction with the text input
    1332              : +
    1333              : +        d.set_FileChooserInput("file", "/home/non-existent/foo")
    1334              : +        b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
    1335              : +
    1336              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1337              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    1338              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1339              : +        b.wait_not_present(".file-chooser")
    1340              : +
    1341              : +        m.upload(["verify/files/file-chooser-test/"], test_files)
    1342              : +        m.execute(f"mkdir '{test_files}/file-chooser-test/empty'")
    1343              : +        d.set_FileChooserInput("file", test_files)
    1344              : +        b.wait_in_text(d.helper_text("file"), "directory")
    1345              : +
    1346              : +        def file(name):
    1347              : +            return f".file-chooser-listing-body tr[data-name='{name}']"
    1348              : +
    1349              : +        # Navigate to empty directory
    1350              : +
    1351              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1352              : +        b.wait_visible(".file-chooser")
    1353              : +        b.mouse(file("cockpittest"), "dblclick")
    1354              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1355              : +        b.mouse(file("empty"), "dblclick")
    1356              : +        b.wait_in_text(".file-chooser-listing-body", "Directory is empty")
    1357              : +
    1358              : +        # Go up and choose tmpdir/file-chooser-test/foo
    1359              : +
    1360              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
    1361              : +        b.assert_pixels(".file-chooser", "basic")
    1362              : +        b.mouse(file("foo"), "click")
    1363              : +        b.click(df.apply_button())
    1364              : +
    1365              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    1366              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1367              : +
    1368              : +        # "foo" should now be in "Recent"
    1369              : +
    1370              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1371              : +        b.wait_visible(".file-chooser-listing-breadcrumbs nav")
    1372              : +        b.wait_visible(file("foo"))
    1373              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    1374              : +        b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
    1375              : +        b.wait_visible(file("foo"))
    1376              : +        b.wait_in_text(file("foo"), test_files + "/file-chooser-test")
    1377              : +        b.mouse(file("foo"), "click")
    1378              : +        b.click(df.apply_button())
    1379              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    1380              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1381              : +
    1382              : +        # Check that "Home" has some expected files
    1383              : +
    1384              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1385              : +        b.click(".file-chooser-sidebar tr:contains('Home')")
    1386              : +        # home can be /home/admin or /var/home/admin.
    1387              : +        b.wait_in_text(".file-chooser-listing-breadcrumbs", "homeadmin")
    1388              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
    1389              : +        b.mouse(file("admin"), "dblclick")
    1390              : +        b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files")
    1391              : +        b.click(".file-chooser-listing-body button:contains('Show hidden files')")
    1392              : +        b.mouse(file(".ssh"), "dblclick")
    1393              : +        b.mouse(file("authorized_keys"), "click")
    1394              : +        b.click(df.apply_button())
    1395              : +
    1396              : +        d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
    1397              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    1398              : +
    1399              : +        # Check the "Downloads" shortcut
    1400              : +
    1401              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1402              : +        b.click(".file-chooser-sidebar tr:contains('Downloads')")
    1403              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads")
    1404              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    1405              : +
    1406              : +        # Check that we can't read /root
    1407              : +
    1408              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    1409              : +        b.mouse(file("root"), "dblclick")
    1410              : +        b.wait_in_text(".file-chooser-listing-body", "Permission denied")
    1411              : +        b.assert_pixels(".file-chooser", "denied")
    1412              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1413              : +        b.wait_not_present(".file-chooser")
    1414              : +
    1415              : +        # Free text filtering
    1416              : +
    1417              : +        d.set_FileChooserInput("file", "")
    1418              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1419              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1420              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1421              : +
    1422              : +        b.wait_visible(file("bar"))
    1423              : +        b.wait_visible(file("foo"))
    1424              : +        b.wait_visible(file("foobar"))
    1425              : +
    1426              : +        b.set_input_text(".file-chooser-listing-header input", "fo")
    1427              : +        b.wait_visible(file("foo"))
    1428              : +        b.wait_not_present(file("bar"))
    1429              : +        b.wait_visible(file("foobar"))
    1430              : +        b.assert_pixels(".file-chooser", "filtered")
    1431              : +
    1432              : +        b.set_input_text(".file-chooser-listing-header input", "ba")
    1433              : +        b.wait_not_present(file("foo"))
    1434              : +        b.wait_visible(file("bar"))
    1435              : +        b.wait_visible(file("foobar"))
    1436              : +
    1437              : +        b.set_input_text(".file-chooser-listing-header input", "xxx")
    1438              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    1439              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    1440              : +
    1441              : +        b.wait_visible(file("bar"))
    1442              : +        b.wait_visible(file("foo"))
    1443              : +        b.wait_visible(file("foobar"))
    1444              : +
    1445              : +        # Prepared filtering.
    1446              : +
    1447              : +        # "No dots" was already active all the time, switch it off to
    1448              : +        # reveal more files.
    1449              : +
    1450              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    1451              : +
    1452              : +        b.wait_visible(file("bar"))
    1453              : +        b.wait_visible(file("foo"))
    1454              : +        b.wait_visible(file("foobar"))
    1455              : +        b.wait_visible(file("dots.txt"))
    1456              : +        b.wait_visible(file("only.dots"))
    1457              : +
    1458              : +        b.mouse(file("only.dots"), "dblclick")
    1459              : +        b.wait_visible(file("one.dot"))
    1460              : +        b.wait_visible(file("two.dots"))
    1461              : +
    1462              : +        b.click(".file-chooser-listing-header button:contains('No dots')")
    1463              : +
    1464              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    1465              : +
    1466              : +        # Filter even more, this should get cleared as well
    1467              : +        b.set_input_text(".file-chooser-listing-header input", "x")
    1468              : +
    1469              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    1470              : +
    1471              : +        b.wait_visible(file("one.dot"))
    1472              : +        b.wait_visible(file("two.dots"))
    1473              : +
    1474              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1475              : +        b.wait_not_present(".file-chooser")
    1476              : +
    1477              : +        # Test the collection
    1478              : +
    1479              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1480              : +        b.click(".file-chooser-sidebar tr:contains('Some files')")
    1481              : +        b.wait_visible(file("foo"))
    1482              : +        b.wait_not_present(file("dots.txt"))
    1483              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    1484              : +        b.wait_visible(file("foo"))
    1485              : +        b.wait_visible(file("dots.txt"))
    1486              : +        b.click(file("dots.txt"))
    1487              : +        b.click(df.apply_button())
    1488              : +
    1489              : +        d.wait_FileChooserInput("file", "/var/lib/cockpittest/file-chooser-test/dots.txt")
    1490              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1491              : +
    1492              : +        # Become superuser and access /root/.ssh
    1493              : +
    1494              : +        b.become_superuser()
    1495              : +
    1496              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1497              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    1498              : +        b.select_PF(".file-chooser-kebab", "Show hidden files")
    1499              : +        b.mouse(file("root"), "dblclick")
    1500              : +        b.mouse(file(".ssh"), "dblclick")
    1501              : +        b.mouse(file("authorized_keys"), "click")
    1502              : +        b.click(df.apply_button())
    1503              : +        d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
    1504              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    1505              : +
    1506              : +        # Select a directory
    1507              : +
    1508              : +        d.wait_FileChooserInput("dir", "")
    1509              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    1510              : +
    1511              : +        b.wait_in_text(".file-chooser-listing-body", "No recent directories")
    1512              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1513              : +        b.click(file("file-chooser-test"))
    1514              : +        b.click(df.apply_button())
    1515              : +
    1516              : +        d.wait_FileChooserInput("dir", test_files + "/file-chooser-test")
    1517              : +
    1518              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    1519              : +        b.wait_visible(file("only.dots"))
    1520              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    1521              : +        b.wait_visible(file("file-chooser-test"))
    1522              : +        b.wait_not_present(file("foo"))
    1523              : +        b.wait_in_text(file("file-chooser-test"), test_files)
    1524              : +
    1525              : +        b.wait_visible(df.apply_button() + "[aria-disabled=true]")
    1526              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1527              : +        b.wait_not_present(".file-chooser")
    1528              : +
    1529              : +        # Check mobile layout
    1530              : +
    1531              : +        b.set_layout("mobile")
    1532              : +
    1533              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1534              : +        b.wait_not_visible(".file-chooser-sidebar")
    1535              : +        b.wait_visible(".file-chooser-kebab")
    1536              : +
    1537              : +        b.select_PF(".file-chooser-kebab", "Test files")
    1538              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1539              : +        b.wait_visible(file("empty"))
    1540              : +
    1541              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1542              : +        b.wait_not_present(".file-chooser")
    1543              : +        b.click(d.cancel_button())
    1544              : +        b.set_layout("desktop")
    1545              : +
    1546              : +        # Stand-alone File Chooser
    1547              : +
    1548              : +        b.click("#open-file-chooser")
    1549              : +        b.wait_visible(".file-chooser")
    1550              : +        b.click(".file-chooser-sidebar tr:contains('Some TXT files')")
    1551              : +        b.wait_visible(file("foo.txt"))
    1552              : +        b.wait_not_present(file("no-such-file.txt"))
    1553              : +        b.click(file("foo.txt"))
    1554              : +        b.wait_text(df.apply_button(), "Load")
    1555              : +        b.click(df.apply_button())
    1556              : +        b.wait_not_present(".file-chooser")
    1557              : +
    1558              : +        b.click("#open-file-chooser")
    1559              : +        b.wait_visible(".file-chooser")
    1560              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1561              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1562              : +        b.mouse(file("text"), "dblclick")
    1563              : +        b.wait_visible(file("foo.txt"))
    1564              : +        b.wait_visible(file("bar.txt"))
    1565              : +        b.click(file("bar.txt"))
    1566              : +        b.click(df.apply_button())
    1567              : +        b.wait_in_text(df.error(), "Does not start with \"foo\"")
    1568              : +        b.click(file("foo.txt"))
    1569              : +        b.click(df.apply_button())
    1570              : +        b.wait_not_present(".file-chooser")
    1571              : +
    1572              :  
    1573              :  if __name__ == '__main__':
    1574              :      testlib.test_main()
    1575              : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
    1576              : new file mode 100644
    1577              : index 000000000..de345c341
    1578              : --- /dev/null
    1579              : +++ b/test/verify/files/file-chooser-test/bar
    1580              : @@ -0,0 +1 @@
    1581              : +Nothing to see.
    1582              : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
    1583              : new file mode 100644
    1584              : index 000000000..0aadcf89b
    1585              : --- /dev/null
    1586              : +++ b/test/verify/files/file-chooser-test/dots.txt
    1587              : @@ -0,0 +1 @@
    1588              : +A file with a dot in its name.
    1589              : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
    1590              : new file mode 100644
    1591              : index 000000000..8159b424a
    1592              : --- /dev/null
    1593              : +++ b/test/verify/files/file-chooser-test/foo
    1594              : @@ -0,0 +1 @@
    1595              : +A file of no consequence.
    1596              : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
    1597              : new file mode 100644
    1598              : index 000000000..896416923
    1599              : --- /dev/null
    1600              : +++ b/test/verify/files/file-chooser-test/foobar
    1601              : @@ -0,0 +1 @@
    1602              : +Can't you think of any other names?
    1603              : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
    1604              : new file mode 100644
    1605              : index 000000000..e69de29bb
    1606              : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
    1607              : new file mode 100644
    1608              : index 000000000..e69de29bb
    1609              : diff --git a/test/verify/files/file-chooser-test/text/bar.txt b/test/verify/files/file-chooser-test/text/bar.txt
    1610              : new file mode 100644
    1611              : index 000000000..ad41127d3
    1612              : --- /dev/null
    1613              : +++ b/test/verify/files/file-chooser-test/text/bar.txt
    1614              : @@ -0,0 +1 @@
    1615              : +No foo here.
    1616              : diff --git a/test/verify/files/file-chooser-test/text/foo.txt b/test/verify/files/file-chooser-test/text/foo.txt
    1617              : new file mode 100644
    1618              : index 000000000..e6f4652aa
    1619              : --- /dev/null
    1620              : +++ b/test/verify/files/file-chooser-test/text/foo.txt
    1621              : @@ -0,0 +1 @@
    1622              : +foo is what I start with
        

Generated by: LCOV version 2.0-1