LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 88.9 % 733 652
Test Date: 2026-07-17 07:33:01

            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..0587bdfc7
       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: black;
      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..840965ad4
      95              : --- /dev/null
      96              : +++ b/pkg/lib/cockpit/react/FileChooser.tsx
      97              : @@ -0,0 +1,967 @@
      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            0 : +            ...(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 : +            cockpit.assert(entry.type);
     354            1 : +            if (!onlyDirectories || entry.type == "dir")
     355            1 : +                result.push({ type: entry.type, name });
     356            1 : +        }
     357              : +
     358            1 : +        result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
     359            1 : +        callback(result);
     360            1 : +    });
     361              : +
     362            1 : +    return client;
     363            1 : +}
     364              : +
     365            1 : +async function getFileInfos(
     366            1 : +    paths: string[],
     367            1 : +    onlyDirectories: boolean,
     368            1 : +    superuser: cockpit.SuperuserMode,
     369            1 : +): Promise<FileInfo[]> {
     370            1 : +    const res: FileInfo[] = [];
     371              : +
     372            1 : +    for (const p of paths) {
     373            1 : +        try {
     374            0 : +            const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
     375            1 : +            if (info.type && (!onlyDirectories || info.type == "dir"))
     376            1 : +                res.push({ name: p, type: info.type });
     377            0 : +        } catch (ex) {
     378            0 : +            if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem != "not-found"))
     379            0 : +                console.error("Failed to get file type:", p);
     380            0 : +        }
     381            1 : +    }
     382              : +
     383            1 : +    return res;
     384            1 : +}
     385              : +
     386            1 : +async function listRecent(recentKey: string): Promise<string[]> {
     387            1 : +    const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
     388            1 : +    if (Array.isArray(recent)) {
     389            1 : +        return recent.filter(r => typeof r == "string");
     390            0 : +    } else {
     391            0 : +        return [];
     392            0 : +    }
     393            1 : +}
     394              : +
     395            1 : +function boldify(name: string, filterText: string): React.ReactNode {
     396            1 : +    if (!filterText)
     397            1 : +        return name;
     398            1 : +    const parts: React.ReactNode[] = [];
     399            1 : +    let pos;
     400            1 : +    while ((pos = name.indexOf(filterText)) >= 0) {
     401            1 : +        parts.push(name.substring(0, pos));
     402            1 : +        parts.push(<u key={pos}>{name.substring(pos, pos + filterText.length)}</u>);
     403            1 : +        name = name.substring(pos + filterText.length);
     404            1 : +    }
     405            1 : +    if (name)
     406            1 : +        parts.push(name);
     407            1 : +    return parts;
     408            1 : +}
     409              : +
     410              : +export interface FileChooserFilter {
     411              : +    label: string;
     412              : +    filter: (name: string, type: string) => boolean,
     413              : +}
     414              : +
     415              : +export interface FileChooserShortcut {
     416              : +    label: string;
     417              : +    path: string;
     418              : +}
     419              : +
     420              : +export interface FileChooserCollection {
     421              : +    label: string;
     422              : +    emptyLabel: string;
     423              : +    list: () => Promise<string[]>;
     424              : +}
     425              : +
     426              : +export interface FileChooserProps {
     427              : +    title: string;
     428              : +    shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
     429              : +    filters?: undefined | FileChooserFilter[];
     430              : +    collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
     431              : +    onlyDirectories?: undefined | boolean;
     432              : +    superuser?: cockpit.SuperuserMode;
     433              : +    recentKey?: undefined | string;
     434              : +    actionLabel?: string;
     435              : +}
     436              : +
     437              : +interface FileChooserValues {
     438              : +    path: string;
     439              : +    collection: null | FileChooserCollection;
     440              : +    files: null | FileError | FileInfo[];
     441              : +    selected: null | FileInfo;
     442              : +    textFilter: string;
     443              : +    filters: FileChooserFilter[];
     444              : +    filter: FileChooserFilter;
     445              : +    recent_collection: FileChooserCollection;
     446              : +    shortcuts: FileChooserShortcut[];
     447              : +    collections: FileChooserCollection[];
     448              : +    showHidden: boolean;
     449              : +}
     450              : +
     451            1 : +export const FileChooser = ({
     452            1 : +    title,
     453            1 : +    shortcuts = [],
     454            1 : +    filters = [],
     455            1 : +    collections = [],
     456            1 : +    onlyDirectories = false,
     457            1 : +    superuser,
     458            1 : +    recentKey = "recent-files",
     459            1 : +    actionLabel,
     460            1 : +    path = "",
     461            1 : +    action,
     462            1 : +} : {
     463              : +    path?: string,
     464              : +    action: (path: string) => Promise<void>,
     465            1 : +} & FileChooserProps) => {
     466            1 : +    const Dialogs = useDialogs();
     467            1 : +    const textInputRef = useRef<HTMLInputElement>(null);
     468            1 : +    const fsInfoClientRef = useRef<FsInfoClient | null>(null);
     469              : +
     470            1 : +    function focusFilter() {
     471            1 : +        textInputRef.current?.focus();
     472            1 : +    }
     473              : +
     474            1 : +    useEffect(() => {
     475            0 : +        textInputRef.current?.focus();
     476            1 : +    }, []);
     477              : +
     478            1 : +    async function init(): Promise<FileChooserValues> {
     479            1 : +        const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
     480              : +
     481            1 : +        const recent_collection = {
     482            1 : +            label: _("Recent"),
     483            1 : +            emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
     484            1 : +            list: () => listRecent(recentKey)
     485            1 : +        };
     486              : +
     487            0 : +        const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
     488            0 : +        const collections_list = Array.isArray(collections) ? collections : await collections();
     489              : +
     490            0 : +        return {
     491            0 : +            path,
     492            0 : +            collection: path == "" ? recent_collection : null,
     493            1 : +            files: null,
     494            1 : +            selected: null,
     495            1 : +            textFilter: "",
     496            1 : +            filters: all_filters,
     497            1 : +            filter: all_filters[0],
     498            1 : +            recent_collection,
     499            1 : +            shortcuts: await stdShortcuts(shortcuts_list),
     500            1 : +            collections: collections_list,
     501            1 : +            showHidden: false,
     502            1 : +        };
     503            1 : +    }
     504              : +
     505            1 : +    const dlg = useDialogState_async(init);
     506              : +
     507            1 : +    const setPath = useCallback(
     508            1 : +        (dlg: DialogState<FileChooserValues>, path: string) => {
     509            1 : +            dlg.field("path").set(path);
     510            1 : +            dlg.field("collection").set(null);
     511            1 : +            dlg.field("selected").set(null);
     512            1 : +            dlg.field("files").set(null);
     513              : +
     514            1 : +            if (fsInfoClientRef.current)
     515            1 : +                fsInfoClientRef.current.close();
     516              : +
     517            1 : +            fsInfoClientRef.current = watchFiles(
     518            1 : +                path,
     519            1 : +                onlyDirectories,
     520            1 : +                superuser,
     521            1 : +                files => {
     522            1 : +                    dlg.field("files").set(files);
     523            1 : +                }
     524            1 : +            );
     525            1 : +        },
     526            1 : +        [onlyDirectories, superuser],
     527            1 : +    );
     528              : +
     529            1 : +    const setCollection = useCallback(
     530            1 : +        (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
     531            1 : +            dlg.field("path").set("");
     532            1 : +            dlg.field("collection").set(collection);
     533            1 : +            dlg.field("selected").set(null);
     534            1 : +            dlg.field("files").set(null);
     535              : +
     536            1 : +            if (fsInfoClientRef.current)
     537            1 : +                fsInfoClientRef.current.close();
     538              : +
     539            1 : +            fsInfoClientRef.current = null;
     540            1 : +            dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
     541            1 : +        },
     542            1 : +        [onlyDirectories, superuser],
     543            1 : +    );
     544              : +
     545            1 : +    useEffect(() => {
     546            1 : +        if (dlg instanceof DialogState) {
     547            1 : +            if (dlg.values.collection)
     548            1 : +                setCollection(dlg, dlg.values.collection);
     549              : +            else
     550            1 : +                setPath(dlg, dlg.values.path);
     551            1 : +        }
     552            1 : +    }, [dlg, setPath, setCollection]);
     553              : +
     554            1 : +    function full_path(path: string, selected: string) {
     555            1 : +        if (path == "")
     556            1 : +            return selected;
     557              : +        else
     558            1 : +            return path_join(path, selected);
     559            1 : +    }
     560              : +
     561            1 : +    function selected_path(): string | null {
     562            1 : +        if (!(dlg instanceof DialogState))
     563            1 : +            return null;
     564              : +
     565            1 : +        const { selected, path } = dlg.values;
     566              : +
     567            1 : +        if (onlyDirectories) {
     568            1 : +            if (!selected && path != "")
     569            1 : +                return path;
     570            1 : +            else if (selected && selected.type == "dir")
     571            1 : +                return full_path(path, selected.name);
     572            1 : +        } else {
     573            1 : +            if (selected && selected.type != "dir")
     574            1 : +                return full_path(path, selected.name);
     575            1 : +        }
     576              : +
     577            1 : +        return null;
     578            1 : +    }
     579              : +
     580            1 : +    async function onAction() {
     581            1 : +        const full = selected_path();
     582            1 : +        cockpit.assert(full);
     583            1 : +        rememberRecent(full, recentKey);
     584            1 : +        await action(full);
     585            1 : +    }
     586              : +
     587            1 : +    function breadcrumbs(dlg: DialogState<FileChooserValues>) {
     588            1 : +        const { path } = dlg.values;
     589              : +
     590            1 : +        if (path == "") {
     591              : +            // Collection
     592            1 : +            return null;
     593            1 : +        } else {
     594            1 : +            const dirs = ["/"].concat(path.split("/").filter(d => !!d));
     595            1 : +            const crumbs: React.ReactNode[] = [];
     596            1 : +            let full = "/";
     597            1 : +            dirs.forEach((d, i) => {
     598            1 : +                if (d != "/")
     599            1 : +                    full = path_join(full, d);
     600            1 : +                const path = full;
     601            1 : +                crumbs.push(
     602            1 : +                    <BreadcrumbItem
     603            1 : +                        key={i}
     604            1 : +                        to="#"
     605            1 : +                        onClick={
     606            1 : +                            (event) => {
     607            1 : +                                setPath(dlg, path);
     608            1 : +                                event.preventDefault();
     609            1 : +                            }
     610              : +                        }
     611            1 : +                        isActive={i == dirs.length - 1}
     612              : +                    >
     613            1 : +                        { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
     614            1 : +                    </BreadcrumbItem>
     615            1 : +                );
     616            1 : +            });
     617              : +
     618            1 : +            if (crumbs.length > 0) {
     619            1 : +                return (
     620            1 : +                    <Breadcrumb>
     621            1 : +                        {crumbs}
     622            1 : +                    </Breadcrumb>
     623              : +                );
     624            1 : +            }
     625            1 : +        }
     626            1 : +    }
     627              : +
     628            1 : +    function header(dlg: DialogState<FileChooserValues>) {
     629            1 : +        const preparedFilters = (
     630            1 : +            dlg.values.filters.length > 1 &&
     631            1 : +                <ToggleGroup>
     632              : +                    {
     633            1 : +                        dlg.values.filters.map(f => {
     634            1 : +                            return (
     635            1 : +                                <ToggleGroupItem
     636            1 : +                                    key={f.label}
     637            1 : +                                    isSelected={f == dlg.values.filter}
     638            1 : +                                    onChange={() => {
     639            1 : +                                        dlg.field("filter").set(f);
     640            1 : +                                        focusFilter();
     641            1 : +                                    }}
     642            1 : +                                    text={f.label}
     643            1 : +                                />
     644              : +                            );
     645            1 : +                        })
     646              : +                    }
     647            1 : +                </ToggleGroup>
     648              : +        );
     649              : +
     650            1 : +        const textFilter = (
     651            1 : +            <TextInput
     652            1 : +                ref={textInputRef}
     653            1 : +                placeholder={_("Type to filter")}
     654            1 : +                value={dlg.values.textFilter}
     655            1 : +                onChange={(_event, value) => dlg.field("textFilter").set(value)}
     656            1 : +            />
     657              : +        );
     658              : +
     659            1 : +        function shortcut(sc: FileChooserShortcut) {
     660            1 : +            return (
     661            1 : +                <DropdownItem
     662            1 : +                    key={sc.label}
     663            0 : +                    onClick={() => setPath(dlg, sc.path)}
     664            1 : +                    className="file-chooser-hide-on-wide"
     665              : +                >
     666            1 : +                    {sc.label}
     667            1 : +                </DropdownItem>
     668              : +            );
     669            1 : +        }
     670              : +
     671            1 : +        function collection(cl: FileChooserCollection) {
     672            1 : +            return (
     673            1 : +                <DropdownItem
     674            1 : +                    key={cl.label}
     675            0 : +                    onClick={() => setCollection(dlg, cl)}
     676            1 : +                    className="file-chooser-hide-on-wide"
     677              : +                >
     678            1 : +                    {cl.label}
     679            1 : +                </DropdownItem>
     680              : +            );
     681            1 : +        }
     682              : +
     683            1 : +        return (
     684            1 : +            <Flex>
     685            1 : +                <FlexItem>
     686            1 : +                    {textFilter}
     687            1 : +                </FlexItem>
     688            1 : +                <FlexItem>
     689            1 : +                    {preparedFilters}
     690            1 : +                </FlexItem>
     691            1 : +                <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
     692            1 : +                    <KebabDropdown
     693            1 : +                        dropdownItems={
     694            1 : +                            [
     695            1 : +                                <DropdownItem
     696            1 : +                                    key="jump"
     697            1 : +                                    onClick={
     698            0 : +                                        () => {
     699            0 : +                                            cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
     700            0 : +                                        }
     701              : +                                    }
     702            1 : +                                    isDisabled={dlg.values.path === ""}
     703              : +                                >
     704            1 : +                                    {_("Open in file browser")}
     705            1 : +                                </DropdownItem>,
     706            1 : +                                <DropdownItem
     707            1 : +                                    key="showhide"
     708            1 : +                                    onClick={
     709            1 : +                                        () => {
     710            1 : +                                            dlg.field("showHidden").set(!dlg.values.showHidden);
     711            1 : +                                        }
     712              : +                                    }
     713              : +                                >
     714            1 : +                                    {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
     715            1 : +                                </DropdownItem>,
     716            1 : +                                <Divider key="divider" className="file-chooser-hide-on-wide" />,
     717            1 : +                                collection(dlg.values.recent_collection),
     718            1 : +                                ...dlg.values.shortcuts.map(shortcut),
     719            1 : +                                shortcut({ label: _("Filesystem"), path: "/" }),
     720            1 : +                                ...dlg.values.collections.map(collection)
     721            1 : +                            ]
     722              : +                        }
     723            1 : +                    />
     724            1 : +                </FlexItem>
     725            1 : +            </Flex>
     726              : +        );
     727            1 : +    }
     728              : +
     729            1 : +    function formatIcon(f: FileInfo): React.ReactNode {
     730            1 : +        if (f.type == "dir")
     731            1 : +            return <FolderIcon />;
     732              : +        else
     733            1 : +            return <FileIcon />;
     734            1 : +    }
     735              : +
     736            1 : +    function sidebar(dlg: DialogState<FileChooserValues>) {
     737            1 : +        function shortcut(sc: FileChooserShortcut) {
     738            1 : +            return (
     739            1 : +                <Tr
     740            1 : +                    key={sc.label}
     741            1 : +                    isClickable
     742            1 : +                    isSelectable
     743            1 : +                    isRowSelected={dlg.values.path == sc.path}
     744            1 : +                    onRowClick={
     745            1 : +                        () => {
     746            1 : +                            setPath(dlg, sc.path);
     747            1 : +                            focusFilter();
     748            1 : +                        }
     749              : +                    }
     750              : +                >
     751            1 : +                    <Td>{sc.label}</Td>
     752            1 : +                </Tr>
     753              : +            );
     754            1 : +        }
     755              : +
     756            1 : +        function collection(col: FileChooserCollection) {
     757            1 : +            return (
     758            1 : +                <Tr
     759            1 : +                    key={col.label}
     760            1 : +                    isClickable
     761            1 : +                    isSelectable
     762            1 : +                    isRowSelected={dlg.values.collection == col}
     763            1 : +                    onRowClick={
     764            1 : +                        () => {
     765            1 : +                            setCollection(dlg, col);
     766            1 : +                            focusFilter();
     767            1 : +                        }
     768              : +                    }
     769              : +                >
     770            1 : +                    <Td>{col.label}</Td>
     771            1 : +                </Tr>
     772              : +            );
     773            1 : +        }
     774              : +
     775            1 : +        return (
     776            1 : +            <Table variant="compact" borders={false}>
     777            1 : +                <Tbody>
     778            1 : +                    { collection(dlg.values.recent_collection) }
     779            1 : +                    { dlg.values.shortcuts.map(shortcut) }
     780            1 : +                    { shortcut({ label: _("Filesystem"), path: "/" }) }
     781            1 : +                    { dlg.values.collections.map(collection) }
     782            1 : +                </Tbody>
     783            1 : +            </Table>
     784              : +        );
     785            1 : +    }
     786              : +
     787            1 : +    function listing(dlg: DialogState<FileChooserValues>) {
     788            1 : +        function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
     789            1 : +            return (
     790            1 : +                <Caption>
     791            1 : +                    <EmptyState
     792            1 : +                        titleText={content}
     793            1 : +                        icon={icon}
     794              : +                    >
     795            1 : +                        { (clearFilters > 0) &&
     796            1 : +                            <EmptyStateActions>
     797            1 : +                                <Button
     798            1 : +                                    variant="link"
     799            1 : +                                    onClick={() => {
     800            1 : +                                        if (clearFilters == 3) {
     801            1 : +                                            dlg.field("showHidden").set(true);
     802            1 : +                                        } else {
     803            1 : +                                            dlg.field("textFilter").set("");
     804            1 : +                                            if (clearFilters > 1)
     805            1 : +                                                dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
     806            1 : +                                        }
     807            1 : +                                        focusFilter();
     808            1 : +                                    }}
     809              : +                                >
     810            1 : +                                    {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
     811            1 : +                                </Button>
     812            1 : +                            </EmptyStateActions>
     813              : +                        }
     814            1 : +                    </EmptyState>
     815            1 : +                </Caption>
     816              : +            );
     817            1 : +        }
     818              : +
     819            1 : +        function listingBody() {
     820            1 : +            const files = dlg.values.files;
     821              : +
     822            1 : +            if (files == null)
     823            1 : +                return emptyState("", Spinner);
     824              : +
     825            1 : +            if (files instanceof FileError)
     826            1 : +                return emptyState(files.message, FolderIcon);
     827              : +
     828            1 : +            if (files.length == 0) {
     829            1 : +                if (dlg.values.collection) {
     830            1 : +                    return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
     831            1 : +                } else if (!onlyDirectories) {
     832            1 : +                    return emptyState(_("Directory is empty"), FolderIcon);
     833            0 : +                } else {
     834            0 : +                    return emptyState(_("Directory has no sub-directories"), FolderIcon);
     835            0 : +                }
     836            1 : +            }
     837              : +
     838            1 : +            const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
     839            1 : +            if (withoutHidden.length == 0)
     840            1 : +                return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
     841              : +
     842            1 : +            const preFiltered = withoutHidden.filter(
     843            1 : +                f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
     844            1 : +            );
     845            1 : +            if (preFiltered.length == 0)
     846            1 : +                return emptyState(_("No matching results"), SearchIcon, 2);
     847              : +
     848            1 : +            const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
     849            1 : +            if (filtered.length == 0)
     850            1 : +                return emptyState(_("No matching results"), SearchIcon, 1);
     851              : +
     852            1 : +            return (
     853            1 : +                <Tbody>
     854              : +                    {
     855            1 : +                        filtered.map(
     856            1 : +                            (f, idx) => {
     857            1 : +                                let name, location;
     858            1 : +                                if (dlg.values.path == "") {
     859            1 : +                                    name = basename(f.name);
     860            1 : +                                    location = dirname(f.name);
     861            1 : +                                } else {
     862            1 : +                                    name = f.name;
     863            1 : +                                }
     864            1 : +                                return (
     865            1 : +                                    <Tr
     866            1 : +                                        className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
     867            1 : +                                        key={idx}
     868            1 : +                                        data-name={name}
     869            1 : +                                        onRowClick={
     870            1 : +                                            () => {
     871            1 : +                                                dlg.field("selected").set(f);
     872            1 : +                                                focusFilter();
     873            1 : +                                            }
     874              : +                                        }
     875            1 : +                                        onDoubleClick={
     876            1 : +                                            event => {
     877            1 : +                                                event.preventDefault();
     878            1 : +                                                if (f.type == "dir")
     879            1 : +                                                    setPath(dlg, full_path(dlg.values.path, f.name));
     880            1 : +                                                dlg.field("textFilter").set("");
     881            1 : +                                                focusFilter();
     882            1 : +                                            }
     883              : +                                        }
     884            1 : +                                        isClickable
     885              : +                                    >
     886            1 : +                                        <Td>
     887            1 : +                                            {formatIcon(f)}
     888              : +                                            &nbsp;&nbsp;
     889            1 : +                                            {boldify(name, dlg.values.textFilter)}
     890            1 : +                                        </Td>
     891            1 : +                                        { location && <Td>{location}</Td> }
     892            1 : +                                    </Tr>
     893              : +                                );
     894            1 : +                            }
     895            1 : +                        )
     896              : +                    }
     897            1 : +                </Tbody>
     898              : +            );
     899            1 : +        }
     900              : +
     901            1 : +        return (
     902            1 : +            <Table variant="compact" borders={false}>
     903            1 : +                { listingBody() }
     904            1 : +            </Table>
     905              : +        );
     906            1 : +    }
     907              : +
     908            1 : +    return (
     909            1 : +        <Modal
     910            1 : +            isOpen
     911            1 : +            variant="large"
     912            1 : +            position="top"
     913            1 : +            onClose={Dialogs.close}
     914            1 : +            className="file-chooser"
     915              : +        >
     916            1 : +            <ModalHeader
     917            1 : +                title={title}
     918            1 : +                description={<DialogErrorMessage dialog={dlg} />}
     919            1 : +            />
     920            1 : +            <ModalBody>
     921            1 : +                <div className="file-chooser-body">
     922            1 : +                    <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
     923              : +                        {
     924            1 : +                            dlg instanceof DialogState
     925            1 : +                                ? sidebar(dlg)
     926            1 : +                                : <Bullseye><Spinner /></Bullseye>
     927              : +                        }
     928            1 : +                    </div>
     929            1 : +                    <div className="file-chooser-listing-header">
     930            1 : +                        { dlg instanceof DialogState && header(dlg) }
     931            1 : +                    </div>
     932            1 : +                    <div className="file-chooser-listing-breadcrumbs">
     933            1 : +                        { dlg instanceof DialogState && breadcrumbs(dlg) }
     934            1 : +                    </div>
     935            1 : +                    <div className="file-chooser-listing-body">
     936            1 : +                        { dlg instanceof DialogState && listing(dlg) }
     937            1 : +                    </div>
     938            1 : +                </div>
     939            1 : +            </ModalBody>
     940            1 : +            <ModalFooter>
     941            1 : +                <DialogActionButton
     942            1 : +                    dialog={dlg}
     943            1 : +                    isAriaDisabled={selected_path() === null}
     944            1 : +                    action={onAction}
     945            1 : +                    onClose={Dialogs.close}
     946              : +                >
     947            1 : +                    {actionLabel || _("Select")}
     948            1 : +                </DialogActionButton>
     949            1 : +            </ModalFooter>
     950            1 : +        </Modal>
     951              : +    );
     952            1 : +};
     953              : +
     954            2 : +const FileChooserButton = ({
     955            2 : +    value,
     956            2 : +    onChoose,
     957            2 : +    props,
     958            2 : +} : {
     959              : +    value: string,
     960              : +    onChoose: (path: string) => void,
     961              : +    props: FileChooserProps,
     962            2 : +}) => {
     963            2 : +    const Dialogs = useDialogs();
     964              : +
     965            2 : +    return (
     966            2 : +        <Button
     967            2 : +            variant="plain"
     968            2 : +            icon={<FolderOpenIcon />}
     969            2 : +            onClick={
     970            1 : +                async () => {
     971            1 : +                    Dialogs.show(
     972            1 : +                        <FileChooser
     973            1 : +                            path={value[0] == "/" ? (props.onlyDirectories ? value : dirname(value)) : ""}
     974            1 : +                            action={async path => onChoose(path)}
     975            1 : +                            {...props}
     976            1 : +                        />
     977            1 : +                    );
     978            1 : +                }
     979              : +            }
     980            2 : +        />
     981              : +    );
     982            2 : +};
     983              : +
     984            2 : +export const FileChooserInput = ({
     985            2 : +    ouiaId,
     986            2 : +    placeholder = "",
     987            2 : +    value,
     988            2 : +    onChange,
     989            2 : +    isDisabled = false,
     990            2 : +    fileChooserProps,
     991            2 : +} : {
     992              : +    ouiaId?: undefined | string;
     993              : +    placeholder?: string,
     994              : +    value: string,
     995              : +    onChange: (path: string, from_dialog: boolean) => void,
     996              : +    isDisabled?: boolean,
     997              : +    fileChooserProps: FileChooserProps,
     998            2 : +}) => {
     999            2 : +    return (
    1000            2 : +        <TextInputGroup
    1001            2 : +            isDisabled={isDisabled}
    1002            2 : +            data-ouia-component-id={ouiaId}
    1003              : +        >
    1004            2 : +            <TextInputGroupMain
    1005            2 : +                value={value}
    1006            2 : +                placeholder={placeholder}
    1007            1 : +                onChange={(_event, value) => onChange(value, false)}
    1008            2 : +                autoComplete="off"
    1009            2 : +            />
    1010            2 : +            <TextInputGroupUtilities>
    1011            2 : +                <WithDialogs>
    1012            2 : +                    <FileChooserButton
    1013            2 : +                        value={value}
    1014            1 : +                        onChoose={value => onChange(value, true)}
    1015            2 : +                        props={fileChooserProps}
    1016            2 : +                    />
    1017            2 : +                </WithDialogs>
    1018            2 : +            </TextInputGroupUtilities>
    1019            2 : +        </TextInputGroup>
    1020              : +    );
    1021            2 : +};
    1022              : +
    1023            2 : +export const DialogFileChooserInput = ({
    1024            2 : +    field,
    1025            2 : +    label,
    1026            2 : +    placeholder = "",
    1027            2 : +    explanation,
    1028            2 : +    warning,
    1029            2 : +    excuse,
    1030            2 : +    fileChooserProps,
    1031            2 : +} : {
    1032              : +    field: DialogField<string>,
    1033              : +    label: string,
    1034              : +    placeholder?: string,
    1035              : +    explanation?: React.ReactNode,
    1036              : +    warning?: React.ReactNode,
    1037              : +    excuse?: string | null | undefined | false,
    1038              : +    fileChooserProps: FileChooserProps,
    1039            2 : +}) => {
    1040            2 : +    return (
    1041            2 : +        <OptionalFormGroup
    1042            2 : +            label={label}
    1043              : +        >
    1044            2 : +            <FileChooserInput
    1045            2 : +                ouiaId={field.ouia_id()}
    1046            2 : +                placeholder={placeholder}
    1047            2 : +                value={field.get()}
    1048            1 : +                onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
    1049            2 : +                isDisabled={!!excuse}
    1050            2 : +                fileChooserProps={fileChooserProps}
    1051            2 : +            />
    1052            2 : +            <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
    1053            2 : +        </OptionalFormGroup>
    1054              : +    );
    1055            2 : +};
    1056              : +
    1057            1 : +export function rememberRecent(name: string, recentKey: string = "recent-files") {
    1058            1 : +    const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
    1059            1 : +    if (Array.isArray(value)) {
    1060            1 : +        const recent = value.filter(r => typeof r == "string" && r != name);
    1061            1 : +        recent.unshift(name);
    1062            1 : +        window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
    1063            1 : +    }
    1064            1 : +}
    1065              : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
    1066              : index 58f890d56..4a4e0a36f 100644
    1067              : --- a/pkg/playground/dialog.tsx
    1068              : +++ b/pkg/playground/dialog.tsx
    1069              : @@ -36,6 +36,8 @@ import {
    1070              :      DialogActionButton, DialogCancelButton,
    1071              :  } from 'cockpit/dialog';
    1072              :  
    1073              : +import { FileChooser, DialogFileChooserInput } from "cockpit/react/FileChooser";
    1074              : +
    1075              :  import 'cockpit-dark-theme'; // once per page
    1076              :  import 'page.scss';
    1077              :  
    1078              : @@ -218,6 +220,9 @@ interface ExampleValues {
    1079              :      alternative: false | string;
    1080              :      error: string;
    1081              :      allow_force: boolean;
    1082              : +    file: string;
    1083              : +    file_explanation: string;
    1084              : +    dir: string;
    1085              :  }
    1086              :  
    1087              :  const ExampleDialog = ({
    1088              : @@ -246,6 +251,9 @@ const ExampleDialog = ({
    1089              :          alternative: false,
    1090              :          error: "none",
    1091              :          allow_force: false,
    1092            2 : +        file: "",
    1093            2 : +        file_explanation: "",
    1094            2 : +        dir: "",
    1095              :      };
    1096              :  
    1097              :      function validate(dlg: DialogState<ExampleValues>) {
    1098              : @@ -270,6 +278,10 @@ const ExampleDialog = ({
    1099              :              });
    1100              :          });
    1101              :          dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
    1102            1 : +        dlg.field("file").validate(v => {
    1103            0 : +            if (v && v[0] != "/")
    1104            0 : +                return "Must be absolute";
    1105            1 : +        });
    1106              :      }
    1107              :  
    1108              :      const dlg = useDialogState(init, validate);
    1109              : @@ -317,6 +329,15 @@ const ExampleDialog = ({
    1110              :          });
    1111              :      }
    1112              :  
    1113            1 : +    function file_changed(val: string) {
    1114            1 : +        dlg.field("file_explanation").set_async(async () => {
    1115            1 : +            if (val[0] == "/")
    1116            1 : +                return cockpit.spawn(["file", "-b", val], { superuser: "try" });
    1117              : +            else
    1118            1 : +                return "--";
    1119            1 : +        });
    1120            1 : +    }
    1121              : +
    1122              :      return (
    1123              :          <Modal
    1124              :              id="dialog"
    1125              : @@ -413,6 +434,49 @@ const ExampleDialog = ({
    1126              :                          checkbox_label="Allow force"
    1127              :                          field={dlg.field("allow_force")}
    1128              :                      />
    1129            2 : +                    <DialogFileChooserInput
    1130            2 : +                        label="File"
    1131            2 : +                        field={dlg.field("file", file_changed)}
    1132            2 : +                        explanation={dlg.values.file_explanation}
    1133            2 : +                        fileChooserProps={
    1134            2 : +                            {
    1135            2 : +                                title: "Select a file",
    1136            2 : +                                superuser: "try",
    1137            2 : +                                filters: [
    1138            1 : +                                    { label: "No dots", filter: n => !n.includes(".") },
    1139            2 : +                                ],
    1140            2 : +                                shortcuts: [
    1141            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1142            2 : +                                ],
    1143            2 : +                                collections: [
    1144            2 : +                                    {
    1145            2 : +                                        label: "Some files",
    1146            2 : +                                        emptyLabel: "Nothing there",
    1147            1 : +                                        list: async () => {
    1148            1 : +                                            return [
    1149            1 : +                                                "/var/lib/cockpittest/file-chooser-test/dots.txt",
    1150            1 : +                                                "/var/lib/cockpittest/file-chooser-test/foo",
    1151            1 : +                                            ];
    1152            1 : +                                        }
    1153            2 : +                                    }
    1154            2 : +                                ]
    1155            2 : +                            }
    1156              : +                        }
    1157            2 : +                    />
    1158            2 : +                    <DialogFileChooserInput
    1159            2 : +                        label="Directory"
    1160            2 : +                        field={dlg.field("dir")}
    1161            2 : +                        fileChooserProps={
    1162            2 : +                            {
    1163            2 : +                                title: "Select a directory",
    1164            2 : +                                onlyDirectories: true,
    1165            2 : +                                superuser: "try",
    1166            2 : +                                shortcuts: [
    1167            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1168            2 : +                                ],
    1169            2 : +                            }
    1170              : +                        }
    1171            2 : +                    />
    1172              :                  </Form>
    1173              :              </ModalBody>
    1174              :              <ModalFooter>
    1175              : @@ -695,6 +759,65 @@ const SimpleExampleButtons = () => {
    1176              :      );
    1177              :  };
    1178              :  
    1179            2 : +const FileChooserButton = () => {
    1180            2 : +    const Dialogs = useDialogs();
    1181              : +
    1182            0 : +    async function loadFile(path: string) {
    1183            0 : +        const data = await cockpit.file(path).read();
    1184            0 : +        if (!data.startsWith("foo"))
    1185            0 : +            throw new Error("Does not start with \"foo\"");
    1186            0 : +    }
    1187              : +
    1188            2 : +    return (
    1189            2 : +        <Button
    1190            2 : +            id="open-file-chooser"
    1191            2 : +            onClick={
    1192            0 : +                () => Dialogs.show(
    1193            0 : +                    <FileChooser
    1194            0 : +                        title={"Select a file that starts with \"foo\""}
    1195            0 : +                        actionLabel="Load"
    1196            0 : +                        action={loadFile}
    1197            0 : +                        filters={
    1198            0 : +                            [
    1199            0 : +                                {
    1200            0 : +                                    label: "TXT files",
    1201            0 : +                                    filter: (name, type) => type == "reg" && !!name.match("\\.txt$")
    1202            0 : +                                },
    1203            0 : +                            ]
    1204              : +                        }
    1205            0 : +                        shortcuts={
    1206            0 : +                            async () => {
    1207            0 : +                                async_sleep(500);
    1208            0 : +                                return [
    1209            0 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1210            0 : +                                ];
    1211            0 : +                            }
    1212              : +                        }
    1213            0 : +                        collections={
    1214            0 : +                            async () => {
    1215            0 : +                                return [
    1216            0 : +                                    {
    1217            0 : +                                        label: "Some TXT files",
    1218            0 : +                                        emptyLabel: "Nothing there",
    1219            0 : +                                        list: async () => {
    1220            0 : +                                            return [
    1221            0 : +                                                "/var/lib/cockpittest/file-chooser-test/text/foo.txt",
    1222            0 : +                                                "/var/lib/cockpittest/file-chooser-test/no-such-file.txt"
    1223            0 : +                                            ];
    1224            0 : +                                        }
    1225            0 : +                                    }
    1226            0 : +                                ];
    1227            0 : +                            }
    1228              : +                        }
    1229            0 : +                    />
    1230            0 : +                )
    1231              : +            }
    1232            2 : +        >
    1233              : +            Open FileChooser
    1234            2 : +        </Button>
    1235              : +    );
    1236            2 : +};
    1237              : +
    1238              :  const Demo = () => {
    1239              :      return (
    1240              :          <WithDialogs>
    1241              : @@ -702,6 +825,7 @@ const Demo = () => {
    1242              :                  <PageSection>
    1243              :                      <ExampleButton />
    1244              :                      <SimpleExampleButtons />
    1245            2 : +                    <FileChooserButton />
    1246              :                  </PageSection>
    1247              :              </Page>
    1248              :          </WithDialogs>
    1249              : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
    1250              : index d9f040327..b2f5981c7 100644
    1251              : --- a/test/common/dialoglib.py
    1252              : +++ b/test/common/dialoglib.py
    1253              : @@ -148,3 +148,14 @@ class DialogHelpers:
    1254              :  
    1255              :      def set_DropdownSelect(self, path: str, val: str) -> None:
    1256              :          self.browser.select_from_dropdown(self.field(path), val)
    1257              : +
    1258              : +    # FileChooserInput
    1259              : +
    1260              : +    def get_FileChooserInput(self, path: str) -> str:
    1261              : +        return self.browser.val(self.field(path) + " input")
    1262              : +
    1263              : +    def wait_FileChooserInput(self, path: str, val: str):
    1264              : +        self.browser.wait_val(self.field(path) + " input", val)
    1265              : +
    1266              : +    def set_FileChooserInput(self, path: str, val: str) -> None:
    1267              : +        self.browser.set_input_text(self.field(path) + " input", val)
    1268              : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
    1269              : index f614daa33..8b5fb3a75 100755
    1270              : --- a/test/verify/check-dialog
    1271              : +++ b/test/verify/check-dialog
    1272              : @@ -350,6 +350,283 @@ class TestDialog(testlib.MachineCase):
    1273              :          b.click(d.cancel_button())
    1274              :          b.wait_not_present("#dialog")
    1275              :  
    1276              : +    def testFileChooser(self):
    1277              : +        b = self.browser
    1278              : +        m = self.machine
    1279              : +        d = dialoglib.DialogHelpers(b, "#dialog")
    1280              : +        df = dialoglib.DialogHelpers(b, ".file-chooser")
    1281              : +
    1282              : +        # Inject a mock xdg-user-dir utility.
    1283              : +
    1284              : +        self.write_file("/usr/local/bin/xdg-user-dir",
    1285              : +"""#! /bin/sh
    1286              : +echo $HOME/Downloads
    1287              : +""", perm="a+x")
    1288              : +
    1289              : +        # Where our test files are. This is intended to be the same as
    1290              : +        # self.vm_tmpdir, but it is also hard-coded into
    1291              : +        # pkg/playground/dialog.tsx and so we hard-code it here as
    1292              : +        # well.
    1293              : +
    1294              : +        test_files = "/var/lib/cockpittest"
    1295              : +
    1296              : +        self.login_and_go("/playground/dialog", superuser=False)
    1297              : +
    1298              : +        b.click("#open")
    1299              : +
    1300              : +        # Use the get_FileChooserInput method so that Vulture doesn't
    1301              : +        # complain about it being unused.
    1302              : +
    1303              : +        self.assertEqual(d.get_FileChooserInput("file"), "")
    1304              : +
    1305              : +        # The first open has a empty Recent tab.
    1306              : +
    1307              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1308              : +        b.wait_in_text(".file-chooser-listing-body", "No recent files")
    1309              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1310              : +        b.wait_not_present(".file-chooser")
    1311              : +
    1312              : +        # Basic interaction with the text input
    1313              : +
    1314              : +        d.set_FileChooserInput("file", "/home/non-existent/foo")
    1315              : +        b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
    1316              : +
    1317              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1318              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    1319              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1320              : +        b.wait_not_present(".file-chooser")
    1321              : +
    1322              : +        m.upload(["verify/files/file-chooser-test/"], test_files)
    1323              : +        m.execute(f"mkdir '{test_files}/file-chooser-test/empty'")
    1324              : +        d.set_FileChooserInput("file", test_files)
    1325              : +        b.wait_in_text(d.helper_text("file"), "directory")
    1326              : +
    1327              : +        def file(name):
    1328              : +            return f".file-chooser-listing-body tr[data-name='{name}']"
    1329              : +
    1330              : +        # Navigate to empty directory
    1331              : +
    1332              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1333              : +        b.wait_visible(".file-chooser")
    1334              : +        b.mouse(file("cockpittest"), "dblclick")
    1335              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1336              : +        b.mouse(file("empty"), "dblclick")
    1337              : +        b.wait_in_text(".file-chooser-listing-body", "Directory is empty")
    1338              : +
    1339              : +        # Go up and choose tmpdir/file-chooser-test/foo
    1340              : +
    1341              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
    1342              : +        b.assert_pixels(".file-chooser", "basic")
    1343              : +        b.mouse(file("foo"), "click")
    1344              : +        b.click(df.apply_button())
    1345              : +
    1346              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    1347              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1348              : +
    1349              : +        # "foo" should now be in "Recent"
    1350              : +
    1351              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1352              : +        b.wait_visible(".file-chooser-listing-breadcrumbs nav")
    1353              : +        b.wait_visible(file("foo"))
    1354              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    1355              : +        b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
    1356              : +        b.wait_visible(file("foo"))
    1357              : +        b.wait_in_text(file("foo"), test_files + "/file-chooser-test")
    1358              : +        b.mouse(file("foo"), "click")
    1359              : +        b.click(df.apply_button())
    1360              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    1361              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1362              : +
    1363              : +        # Check that "Home" has some expected files
    1364              : +
    1365              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1366              : +        b.click(".file-chooser-sidebar tr:contains('Home')")
    1367              : +        # home can be /home/admin or /var/home/admin.
    1368              : +        b.wait_in_text(".file-chooser-listing-breadcrumbs", "homeadmin")
    1369              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
    1370              : +        b.mouse(file("admin"), "dblclick")
    1371              : +        b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files")
    1372              : +        b.click(".file-chooser-listing-body button:contains('Show hidden files')")
    1373              : +        b.mouse(file(".ssh"), "dblclick")
    1374              : +        b.mouse(file("authorized_keys"), "click")
    1375              : +        b.click(df.apply_button())
    1376              : +
    1377              : +        d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
    1378              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    1379              : +
    1380              : +        # Check the "Downloads" shortcut
    1381              : +
    1382              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1383              : +        b.click(".file-chooser-sidebar tr:contains('Downloads')")
    1384              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads")
    1385              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    1386              : +
    1387              : +        # Check that we can't read /root
    1388              : +
    1389              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    1390              : +        b.mouse(file("root"), "dblclick")
    1391              : +        b.wait_in_text(".file-chooser-listing-body", "Permission denied")
    1392              : +        b.assert_pixels(".file-chooser", "denied")
    1393              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1394              : +        b.wait_not_present(".file-chooser")
    1395              : +
    1396              : +        # Free text filtering
    1397              : +
    1398              : +        d.set_FileChooserInput("file", "")
    1399              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1400              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1401              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1402              : +
    1403              : +        b.wait_visible(file("bar"))
    1404              : +        b.wait_visible(file("foo"))
    1405              : +        b.wait_visible(file("foobar"))
    1406              : +
    1407              : +        b.set_input_text(".file-chooser-listing-header input", "fo")
    1408              : +        b.wait_visible(file("foo"))
    1409              : +        b.wait_not_present(file("bar"))
    1410              : +        b.wait_visible(file("foobar"))
    1411              : +        b.assert_pixels(".file-chooser", "filtered")
    1412              : +
    1413              : +        b.set_input_text(".file-chooser-listing-header input", "ba")
    1414              : +        b.wait_not_present(file("foo"))
    1415              : +        b.wait_visible(file("bar"))
    1416              : +        b.wait_visible(file("foobar"))
    1417              : +
    1418              : +        b.set_input_text(".file-chooser-listing-header input", "xxx")
    1419              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    1420              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    1421              : +
    1422              : +        b.wait_visible(file("bar"))
    1423              : +        b.wait_visible(file("foo"))
    1424              : +        b.wait_visible(file("foobar"))
    1425              : +
    1426              : +        # Prepared filtering.
    1427              : +
    1428              : +        # "No dots" was already active all the time, switch it off to
    1429              : +        # reveal more files.
    1430              : +
    1431              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    1432              : +
    1433              : +        b.wait_visible(file("bar"))
    1434              : +        b.wait_visible(file("foo"))
    1435              : +        b.wait_visible(file("foobar"))
    1436              : +        b.wait_visible(file("dots.txt"))
    1437              : +        b.wait_visible(file("only.dots"))
    1438              : +
    1439              : +        b.mouse(file("only.dots"), "dblclick")
    1440              : +        b.wait_visible(file("one.dot"))
    1441              : +        b.wait_visible(file("two.dots"))
    1442              : +
    1443              : +        b.click(".file-chooser-listing-header button:contains('No dots')")
    1444              : +
    1445              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    1446              : +
    1447              : +        # Filter even more, this should get cleared as well
    1448              : +        b.set_input_text(".file-chooser-listing-header input", "x")
    1449              : +
    1450              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    1451              : +
    1452              : +        b.wait_visible(file("one.dot"))
    1453              : +        b.wait_visible(file("two.dots"))
    1454              : +
    1455              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1456              : +        b.wait_not_present(".file-chooser")
    1457              : +
    1458              : +        # Test the collection
    1459              : +
    1460              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1461              : +        b.click(".file-chooser-sidebar tr:contains('Some files')")
    1462              : +        b.wait_visible(file("foo"))
    1463              : +        b.wait_not_present(file("dots.txt"))
    1464              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    1465              : +        b.wait_visible(file("foo"))
    1466              : +        b.wait_visible(file("dots.txt"))
    1467              : +        b.click(file("dots.txt"))
    1468              : +        b.click(df.apply_button())
    1469              : +
    1470              : +        d.wait_FileChooserInput("file", "/var/lib/cockpittest/file-chooser-test/dots.txt")
    1471              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    1472              : +
    1473              : +        # Become superuser and access /root/.ssh
    1474              : +
    1475              : +        b.become_superuser()
    1476              : +
    1477              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1478              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    1479              : +        b.select_PF(".file-chooser-kebab", "Show hidden files")
    1480              : +        b.mouse(file("root"), "dblclick")
    1481              : +        b.mouse(file(".ssh"), "dblclick")
    1482              : +        b.mouse(file("authorized_keys"), "click")
    1483              : +        b.click(df.apply_button())
    1484              : +        d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
    1485              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    1486              : +
    1487              : +        # Select a directory
    1488              : +
    1489              : +        d.wait_FileChooserInput("dir", "")
    1490              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    1491              : +
    1492              : +        b.wait_in_text(".file-chooser-listing-body", "No recent directories")
    1493              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1494              : +        b.click(file("file-chooser-test"))
    1495              : +        b.click(df.apply_button())
    1496              : +
    1497              : +        d.wait_FileChooserInput("dir", test_files + "/file-chooser-test")
    1498              : +
    1499              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    1500              : +        b.wait_visible(file("file-chooser-test"))
    1501              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    1502              : +        b.wait_visible(file("file-chooser-test"))
    1503              : +        b.wait_not_present(file("foo"))
    1504              : +        b.wait_in_text(file("file-chooser-test"), test_files)
    1505              : +
    1506              : +        b.wait_visible(df.apply_button() + "[aria-disabled=true]")
    1507              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1508              : +        b.wait_not_present(".file-chooser")
    1509              : +
    1510              : +        # Check mobile layout
    1511              : +
    1512              : +        b.set_layout("mobile")
    1513              : +
    1514              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    1515              : +        b.wait_not_visible(".file-chooser-sidebar")
    1516              : +        b.wait_visible(".file-chooser-kebab")
    1517              : +
    1518              : +        b.select_PF(".file-chooser-kebab", "Test files")
    1519              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1520              : +        b.wait_visible(file("empty"))
    1521              : +
    1522              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    1523              : +        b.wait_not_present(".file-chooser")
    1524              : +        b.click(d.cancel_button())
    1525              : +        b.set_layout("desktop")
    1526              : +
    1527              : +        # Stand-alone File Chooser
    1528              : +
    1529              : +        b.click("#open-file-chooser")
    1530              : +        b.wait_visible(".file-chooser")
    1531              : +        b.click(".file-chooser-sidebar tr:contains('Some TXT files')")
    1532              : +        b.wait_visible(file("foo.txt"))
    1533              : +        b.wait_not_present(file("no-such-file.txt"))
    1534              : +        b.click(file("foo.txt"))
    1535              : +        b.wait_text(df.apply_button(), "Load")
    1536              : +        b.click(df.apply_button())
    1537              : +        b.wait_not_present(".file-chooser")
    1538              : +
    1539              : +        b.click("#open-file-chooser")
    1540              : +        b.wait_visible(".file-chooser")
    1541              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    1542              : +        b.mouse(file("file-chooser-test"), "dblclick")
    1543              : +        b.mouse(file("text"), "dblclick")
    1544              : +        b.wait_visible(file("foo.txt"))
    1545              : +        b.wait_visible(file("bar.txt"))
    1546              : +        b.click(file("bar.txt"))
    1547              : +        b.click(df.apply_button())
    1548              : +        b.wait_in_text(df.error(), "Does not start with \"foo\"")
    1549              : +        b.click(file("foo.txt"))
    1550              : +        b.click(df.apply_button())
    1551              : +        b.wait_not_present(".file-chooser")
    1552              : +
    1553              :  
    1554              :  if __name__ == '__main__':
    1555              :      testlib.test_main()
    1556              : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
    1557              : new file mode 100644
    1558              : index 000000000..de345c341
    1559              : --- /dev/null
    1560              : +++ b/test/verify/files/file-chooser-test/bar
    1561              : @@ -0,0 +1 @@
    1562              : +Nothing to see.
    1563              : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
    1564              : new file mode 100644
    1565              : index 000000000..0aadcf89b
    1566              : --- /dev/null
    1567              : +++ b/test/verify/files/file-chooser-test/dots.txt
    1568              : @@ -0,0 +1 @@
    1569              : +A file with a dot in its name.
    1570              : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
    1571              : new file mode 100644
    1572              : index 000000000..8159b424a
    1573              : --- /dev/null
    1574              : +++ b/test/verify/files/file-chooser-test/foo
    1575              : @@ -0,0 +1 @@
    1576              : +A file of no consequence.
    1577              : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
    1578              : new file mode 100644
    1579              : index 000000000..896416923
    1580              : --- /dev/null
    1581              : +++ b/test/verify/files/file-chooser-test/foobar
    1582              : @@ -0,0 +1 @@
    1583              : +Can't you think of any other names?
    1584              : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
    1585              : new file mode 100644
    1586              : index 000000000..e69de29bb
    1587              : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
    1588              : new file mode 100644
    1589              : index 000000000..e69de29bb
    1590              : diff --git a/test/verify/files/file-chooser-test/text/bar.txt b/test/verify/files/file-chooser-test/text/bar.txt
    1591              : new file mode 100644
    1592              : index 000000000..ad41127d3
    1593              : --- /dev/null
    1594              : +++ b/test/verify/files/file-chooser-test/text/bar.txt
    1595              : @@ -0,0 +1 @@
    1596              : +No foo here.
    1597              : diff --git a/test/verify/files/file-chooser-test/text/foo.txt b/test/verify/files/file-chooser-test/text/foo.txt
    1598              : new file mode 100644
    1599              : index 000000000..e6f4652aa
    1600              : --- /dev/null
    1601              : +++ b/test/verify/files/file-chooser-test/text/foo.txt
    1602              : @@ -0,0 +1 @@
    1603              : +foo is what I start with
        

Generated by: LCOV version 2.0-1