LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 96.1 % 727 699
Test Date: 2026-07-03 07:31:16

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

Generated by: LCOV version 2.0-1