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-02 14:11:36

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

Generated by: LCOV version 2.0-1