LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 96.2 % 739 711
Test Date: 2026-07-17 12:03:54

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

Generated by: LCOV version 2.0-1