LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 96.7 % 1082 1046
Test Date: 2026-06-25 11:17:56

            Line data    Source code
       1              : diff --git a/pkg/lib/cockpit/FileChooser.css b/pkg/lib/cockpit/FileChooser.css
       2              : new file mode 100644
       3              : index 000000000..0587bdfc7
       4              : --- /dev/null
       5              : +++ b/pkg/lib/cockpit/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/FileChooser.tsx b/pkg/lib/cockpit/FileChooser.tsx
      93              : new file mode 100644
      94              : index 000000000..2d8eae8eb
      95              : --- /dev/null
      96              : +++ b/pkg/lib/cockpit/FileChooser.tsx
      97              : @@ -0,0 +1,839 @@
      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              : +
     105              : +   It only implements things that are actually needed right now in
     106              : +   Cockpit and it will be extened as those needs grow.
     107              : +
     108              : +   Here is a list of notable features that are not implemented yet but
     109              : +   have been prototyped elsewhere:
     110              : +
     111              : +   - Support for using the dialog stand-alone without the
     112              : +     FileChooserInput widget.  This includes running arbitrary actions
     113              : +     right in the dialog and displaying their errors.
     114              : +
     115              : +   - Creating new files in a "Save as" scenario.
     116              : +
     117              : +   - Autocompletion in the FileChooserInput.
     118              : + */
     119              : +
     120            2 : +import cockpit from "cockpit";
     121            2 : +import React, { useRef, useEffect } from "react";
     122              : +
     123              : +import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
     124              : +import { Table, Caption, Tbody, Tr, Td } from '@patternfly/react-table';
     125              : +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
     126              : +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
     127              : +import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
     128              : +import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
     129              : +import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
     130              : +import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons';
     131              : +import {
     132              : +    TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
     133              : +} from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
     134              : +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
     135              : +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
     136              : +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
     137              : +import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
     138              : +
     139              : +import { KebabDropdown } from "cockpit-components-dropdown";
     140              : +
     141              : +import { useDialogs, WithDialogs } from 'dialogs';
     142              : +import { useInit } from "hooks";
     143              : +import { FsInfoClient } from "cockpit/fsinfo";
     144              : +import { basename, dirname } from "cockpit-path";
     145              : +
     146              : +import {
     147              : +    useDialogState,
     148              : +    DialogField,
     149              : +    DialogErrorMessage,
     150              : +    DialogHelperText,
     151              : +    OptionalFormGroup,
     152              : +    DialogActionButton,
     153              : +} from 'cockpit/dialog';
     154              : +
     155              : +import "./FileChooser.css";
     156              : +
     157            2 : +const _ = cockpit.gettext;
     158              : +
     159            1 : +const FileIcon = () => {
     160            1 : +    return (
     161            1 : +        <svg
     162            1 : +            height="1em"
     163            1 : +            width="1em"
     164            1 : +            xmlns="http://www.w3.org/2000/svg"
     165            1 : +            viewBox="0 0 1536 1792"
     166            1 : +            fill="currentColor"
     167              : +        >
     168            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" />
     169            1 : +        </svg>
     170              : +    );
     171            1 : +};
     172              : +
     173            1 : +function path_join(dir: string, base: string) {
     174            1 : +    return (dir == "/" ? "" : dir) + "/" + base;
     175            1 : +}
     176              : +
     177              : +interface FileInfo {
     178              : +    type: string;
     179              : +    name: string;
     180              : +}
     181              : +
     182            1 : +function is_FileInfo(obj: unknown): obj is FileInfo {
     183            1 : +    return (
     184            1 : +        !!obj &&
     185            1 : +            typeof obj == "object" &&
     186            1 : +            "name" in obj &&
     187            1 : +            typeof obj.name == "string" &&
     188            1 : +            "type" in obj &&
     189            1 : +            typeof obj.type == "string"
     190              : +    );
     191            1 : +}
     192              : +
     193            2 : +class FileError {
     194              : +    message: string;
     195              : +
     196            1 : +    constructor(message: string) {
     197            1 : +        this.message = message;
     198            1 : +    }
     199            2 : +}
     200              : +
     201            1 : +function watchFiles(
     202            1 : +    path: string,
     203            1 : +    onlyDirectories: boolean,
     204            1 : +    superuser: cockpit.SuperuserMode,
     205            1 : +    callback: (files: FileError | FileInfo[]) => void,
     206            1 : +): null | FsInfoClient {
     207            1 : +    const client = new FsInfoClient(
     208            1 : +        path,
     209            1 : +        ["type", "entries", "target", "targets"],
     210            1 : +        {
     211            1 : +            follow: true,
     212            0 : +            ...(superuser ? { superuser } : { })
     213            1 : +        }
     214            1 : +    );
     215              : +
     216            1 : +    client.on("close", message => {
     217            0 : +        if ("message" in message && typeof message.message == "string")
     218            0 : +            callback(new FileError(message.message));
     219            1 : +    });
     220              : +
     221            1 : +    client.on("change", state => {
     222            1 : +        if (state.error) {
     223            1 : +            callback(new FileError(state.error.message));
     224            1 : +            return;
     225            1 : +        }
     226              : +
     227            1 : +        if (!state.info)
     228            1 : +            return;
     229              : +
     230            1 : +        const info = state.info;
     231              : +
     232            0 : +        if (!(info.type && info.entries && info.targets)) {
     233            0 : +            callback(new FileError(_("Permission denied")));
     234            0 : +            return;
     235            0 : +        }
     236              : +
     237            0 : +        if (info.type != "dir") {
     238            0 : +            callback(new FileError(_("Not a directory")));
     239            0 : +            return;
     240            0 : +        }
     241              : +
     242            1 : +        const result: FileInfo[] = [];
     243            1 : +        for (const name in info.entries) {
     244            1 : +            let entry = info.entries[name];
     245            1 : +            if (entry.type == "lnk" && entry.target)
     246            1 : +                entry = info.entries[entry.target] || info.targets[entry.target];
     247              : +
     248            1 : +            cockpit.assert(entry.type);
     249            1 : +            if (!onlyDirectories || entry.type == "dir")
     250            1 : +                result.push({ type: entry.type, name });
     251            1 : +        }
     252              : +
     253            1 : +        result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
     254            1 : +        callback(result);
     255            1 : +    });
     256              : +
     257            1 : +    return client;
     258            1 : +}
     259              : +
     260            1 : +async function listRecent(onlyDirectories: boolean, recentKey: string) {
     261            1 : +    const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
     262            1 : +    if (Array.isArray(recent)) {
     263            1 : +        return recent.filter(is_FileInfo).filter(fi => !onlyDirectories || fi.type == "dir");
     264            0 : +    } else {
     265            0 : +        return [];
     266            0 : +    }
     267            1 : +}
     268              : +
     269            1 : +function boldify(name: string, filterText: string): React.ReactNode {
     270            1 : +    if (!filterText)
     271            1 : +        return name;
     272            1 : +    const parts: React.ReactNode[] = [];
     273            1 : +    let pos;
     274            1 : +    while ((pos = name.indexOf(filterText)) >= 0) {
     275            1 : +        parts.push(name.substring(0, pos));
     276            1 : +        parts.push(<u key={pos}>{name.substring(pos, pos + filterText.length)}</u>);
     277            1 : +        name = name.substring(pos + filterText.length);
     278            1 : +    }
     279            1 : +    if (name)
     280            1 : +        parts.push(name);
     281            1 : +    return parts;
     282            1 : +}
     283              : +
     284              : +export interface FileChooserFilter {
     285              : +    label: string;
     286              : +    filter: (name: string, type: string) => boolean,
     287              : +}
     288              : +
     289              : +export function regexFilter(label: string, regex: string): FileChooserFilter {
     290              : +    return {
     291              : +        label,
     292              : +        filter: n => !!n.match(regex),
     293              : +    };
     294              : +}
     295              : +
     296              : +interface FileChooserShortcut {
     297              : +    label: string;
     298              : +    path: string;
     299              : +}
     300              : +
     301              : +interface FileChooserCollection {
     302              : +    label: string;
     303              : +    list: () => Promise<FileInfo[]>;
     304              : +}
     305              : +
     306              : +export interface FileChooserProps {
     307              : +    title: string;
     308              : +    shortcuts?: undefined | FileChooserShortcut[];
     309              : +    filters?: undefined | FileChooserFilter[];
     310              : +    collections?: undefined | FileChooserCollection[];
     311              : +    onlyDirectories?: undefined | boolean;
     312              : +    superuser?: cockpit.SuperuserMode;
     313              : +    recentKey?: undefined | string;
     314              : +}
     315              : +
     316              : +interface FileChooserModalValues {
     317              : +    path: string;
     318              : +    collection: null | FileChooserCollection;
     319              : +    files: null | FileError | FileInfo[];
     320              : +    selected: null | FileInfo;
     321              : +    textFilter: string;
     322              : +    filters: FileChooserFilter[];
     323              : +    filter: FileChooserFilter;
     324              : +    recent_collection: FileChooserCollection;
     325              : +    collections: FileChooserCollection[];
     326              : +    showHidden: boolean;
     327              : +}
     328              : +
     329            1 : +const FileChooserModal = ({
     330            1 : +    title,
     331            1 : +    shortcuts = [],
     332            1 : +    filters = [],
     333            1 : +    collections = [],
     334            1 : +    onlyDirectories = false,
     335            1 : +    superuser,
     336            1 : +    recentKey = "recent-files",
     337            1 : +    path = "",
     338            1 : +    onChoose,
     339            1 : +} : {
     340              : +    path?: string,
     341              : +    onChoose: (path: string) => void,
     342            1 : +} & FileChooserProps) => {
     343            1 : +    const Dialogs = useDialogs();
     344            1 : +    const textInputRef = useRef<HTMLInputElement>(null);
     345            1 : +    const fsInfoClientRef = useRef<FsInfoClient | null>(null);
     346              : +
     347            1 : +    function focusFilter() {
     348            1 : +        textInputRef.current?.focus();
     349            1 : +    }
     350              : +
     351            1 : +    useEffect(() => {
     352            0 : +        textInputRef.current?.focus();
     353            1 : +    }, []);
     354              : +
     355            1 : +    function init(): FileChooserModalValues {
     356            1 : +        const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
     357            1 : +        const recent_collection = { label: _("Recent"), list: () => listRecent(onlyDirectories, recentKey) };
     358              : +
     359            1 : +        return {
     360            1 : +            path,
     361            1 : +            collection: path == "" ? recent_collection : null,
     362            1 : +            files: null,
     363            1 : +            selected: null,
     364            1 : +            textFilter: "",
     365            1 : +            filters: all_filters,
     366            1 : +            filter: all_filters[0],
     367            1 : +            recent_collection,
     368            1 : +            collections,
     369            1 : +            showHidden: false,
     370            1 : +        };
     371            1 : +    }
     372              : +
     373            1 : +    const dlg = useDialogState(init);
     374            1 : +    useInit(() => {
     375            1 : +        if (dlg.values.collection)
     376            1 : +            setCollection(dlg.values.collection);
     377              : +        else
     378            1 : +            setPath(dlg.values.path)
     379            1 : +    });
     380              : +
     381            1 : +    function full_path(path: string, selected: string) {
     382            1 : +        if (path == "")
     383            1 : +            return selected;
     384              : +        else
     385            1 : +            return path_join(path, selected);
     386            1 : +    }
     387              : +
     388            1 : +    function selected_path(): string | null {
     389            1 : +        const { selected, path } = dlg.values;
     390              : +
     391            1 : +        if (onlyDirectories) {
     392            1 : +            if (!selected && path != "")
     393            1 : +                return path;
     394            1 : +            else if (selected && selected.type == "dir")
     395            1 : +                return full_path(path, selected.name);
     396            1 : +        } else {
     397            1 : +            if (selected && selected.type != "dir")
     398            1 : +                return full_path(path, selected.name);
     399            1 : +        }
     400              : +
     401            1 : +        return null;
     402            1 : +    }
     403              : +
     404            1 : +    async function onAction(values: FileChooserModalValues) {
     405            1 : +        const full = selected_path();
     406            1 : +        cockpit.assert(full);
     407            0 : +        rememberRecent(full, values.selected ? values.selected.type : "dir", recentKey);
     408            1 : +        onChoose(full);
     409            1 : +    }
     410              : +
     411            1 : +    function onSelect(f: FileInfo) {
     412            1 : +        dlg.field("selected").set(f);
     413            1 : +    }
     414              : +
     415            1 : +    function setPath(path: string) {
     416            1 : +        dlg.field("path").set(path);
     417            1 : +        dlg.field("collection").set(null);
     418            1 : +        dlg.field("selected").set(null);
     419            1 : +        dlg.field("files").set(null);
     420              : +
     421            1 : +        if (fsInfoClientRef.current)
     422            1 : +            fsInfoClientRef.current.close();
     423              : +
     424            1 : +        fsInfoClientRef.current = watchFiles(
     425            1 : +            path,
     426            1 : +            onlyDirectories,
     427            1 : +            superuser,
     428            1 : +            files => {
     429            1 : +                dlg.field("files").set(files);
     430            1 : +            }
     431            1 : +        );
     432            1 : +    }
     433              : +
     434            1 : +    function setCollection(collection: FileChooserCollection) {
     435            1 : +        dlg.field("path").set("");
     436            1 : +        dlg.field("collection").set(collection);
     437            1 : +        dlg.field("selected").set(null);
     438            1 : +        dlg.field("files").set(null);
     439              : +
     440            1 : +        if (fsInfoClientRef.current)
     441            1 : +            fsInfoClientRef.current.close();
     442              : +
     443            1 : +        fsInfoClientRef.current = null;
     444            1 : +        dlg.field("files").set_async(0, () => collection.list());
     445            1 : +    }
     446              : +
     447            1 : +    function onNavigate(f: FileInfo) {
     448            1 : +        if (f.type == "dir") {
     449            1 : +            setPath(full_path(dlg.values.path, f.name));
     450            1 : +        }
     451            1 : +    }
     452              : +
     453            1 : +    function breadcrumbs() {
     454            1 : +        const { path } = dlg.values;
     455              : +
     456            1 : +        if (path == "") {
     457              : +            // Collection
     458            1 : +            return null;
     459            1 : +        } else {
     460            1 : +            const dirs = ["/"].concat(path.split("/").filter(d => !!d));
     461            1 : +            const crumbs: React.ReactNode[] = [];
     462            1 : +            let full = "/";
     463            1 : +            dirs.forEach((d, i) => {
     464            1 : +                if (d != "/")
     465            1 : +                    full = path_join(full, d);
     466            1 : +                const path = full;
     467            1 : +                crumbs.push(
     468            1 : +                    <BreadcrumbItem
     469            1 : +                        key={i}
     470            1 : +                        to="#"
     471            1 : +                        onClick={
     472            1 : +                            (event) => {
     473            1 : +                                setPath(path);
     474            1 : +                                event.preventDefault();
     475            1 : +                            }
     476              : +                        }
     477            1 : +                        isActive={i == dirs.length - 1}
     478              : +                    >
     479            1 : +                        { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
     480            1 : +                    </BreadcrumbItem>
     481            1 : +                );
     482            1 : +            });
     483              : +
     484            1 : +            if (crumbs.length > 0) {
     485            1 : +                return (
     486            1 : +                    <Breadcrumb>
     487            1 : +                        {crumbs}
     488            1 : +                    </Breadcrumb>
     489              : +                );
     490            1 : +            }
     491            1 : +        }
     492            1 : +    }
     493              : +
     494            1 : +    function header() {
     495            1 : +        const preparedFilters = (
     496            1 : +            dlg.values.filters.length > 1 &&
     497            1 : +                <ToggleGroup>
     498              : +                    {
     499            1 : +                        dlg.values.filters.map(f => {
     500            1 : +                            return (
     501            1 : +                                <ToggleGroupItem
     502            1 : +                                    key={f.label}
     503            1 : +                                    isSelected={f == dlg.values.filter}
     504            1 : +                                    onChange={() => {
     505            1 : +                                        dlg.field("filter").set(f);
     506            1 : +                                        focusFilter();
     507            1 : +                                    }}
     508            1 : +                                    text={f.label}
     509            1 : +                                />
     510              : +                            );
     511            1 : +                        })
     512              : +                    }
     513            1 : +                </ToggleGroup>
     514              : +        );
     515              : +
     516            1 : +        const textFilter = (
     517            1 : +            <TextInput
     518            1 : +                ref={textInputRef}
     519            1 : +                placeholder={_("Type to filter")}
     520            1 : +                value={dlg.values.textFilter}
     521            1 : +                onChange={(_event, value) => dlg.field("textFilter").set(value)}
     522            1 : +            />
     523              : +        );
     524              : +
     525            1 : +        function shortcut(sc: FileChooserShortcut) {
     526            1 : +            return (
     527            1 : +                <DropdownItem
     528            1 : +                    key={sc.label}
     529            1 : +                    onClick={() => setPath(sc.path)}
     530            1 : +                    className="file-chooser-hide-on-wide"
     531              : +                >
     532            1 : +                    {sc.label}
     533            1 : +                </DropdownItem>
     534              : +            );
     535            1 : +        }
     536              : +
     537            1 : +        return (
     538            1 : +            <Flex>
     539            1 : +                <FlexItem>
     540            1 : +                    {textFilter}
     541            1 : +                </FlexItem>
     542            1 : +                <FlexItem>
     543            1 : +                    {preparedFilters}
     544            1 : +                </FlexItem>
     545            1 : +                <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
     546            1 : +                    <WithDialogs>
     547            1 : +                        <KebabDropdown
     548            1 : +                            dropdownItems={
     549            1 : +                                [
     550            1 : +                                    <DropdownItem
     551            1 : +                                        key="jump"
     552            1 : +                                        onClick={
     553            0 : +                                            () => {
     554            0 : +                                                cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
     555            0 : +                                            }
     556              : +                                        }
     557            1 : +                                        isDisabled={dlg.values.path === ""}
     558              : +                                    >
     559            1 : +                                        {_("Open in file browser")}
     560            1 : +                                    </DropdownItem>,
     561            1 : +                                    <DropdownItem
     562            1 : +                                        key="showhide"
     563            1 : +                                        onClick={
     564            1 : +                                            () => {
     565            1 : +                                                dlg.field("showHidden").set(!dlg.values.showHidden);
     566            1 : +                                            }
     567              : +                                        }
     568              : +                                    >
     569            1 : +                                        {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
     570            1 : +                                    </DropdownItem>,
     571            1 : +                                    <Divider key="divider" className="file-chooser-hide-on-wide"/>,
     572            1 : +                                    shortcut({ label: _("Recent"), path: "" }),
     573            1 : +                                    ...shortcuts.map(shortcut),
     574            1 : +                                    shortcut({ label: _("Filesystem"), path: "/" }),
     575            1 : +                                ]
     576              : +                            }
     577            1 : +                        />
     578            1 : +                    </WithDialogs>
     579            1 : +                </FlexItem>
     580            1 : +            </Flex>
     581              : +        );
     582            1 : +    }
     583              : +
     584            1 : +    function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
     585            1 : +        return (
     586            1 : +            <Caption>
     587            1 : +                <EmptyState
     588            1 : +                    titleText={content}
     589            1 : +                    icon={icon}
     590              : +                >
     591            1 : +                    { (clearFilters > 0) &&
     592            1 : +                        <EmptyStateActions>
     593            1 : +                            <Button
     594            1 : +                                variant="link"
     595            1 : +                                onClick={() => {
     596            1 : +                                    if (clearFilters == 3) {
     597            1 : +                                        dlg.field("showHidden").set(true);
     598            1 : +                                    } else {
     599            1 : +                                        dlg.field("textFilter").set("");
     600            1 : +                                        if (clearFilters > 1)
     601            1 : +                                            dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
     602            1 : +                                    }
     603            1 : +                                    focusFilter();
     604            1 : +                                }}
     605              : +                            >
     606            1 : +                                {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
     607            1 : +                            </Button>
     608            1 : +                        </EmptyStateActions>
     609              : +                    }
     610            1 : +                </EmptyState>
     611            1 : +            </Caption>
     612              : +        );
     613            1 : +    }
     614              : +
     615            1 : +    function formatIcon(f: FileInfo): React.ReactNode {
     616            1 : +        if (f.type == "dir")
     617            1 : +            return <FolderIcon />;
     618              : +        else
     619            1 : +            return <FileIcon />;
     620            1 : +    }
     621              : +
     622            1 : +    function sidebar() {
     623            1 : +        function shortcut(sc: FileChooserShortcut) {
     624            1 : +            return (
     625            1 : +                <Tr
     626            1 : +                    key={sc.label}
     627            1 : +                    isClickable
     628            1 : +                    isSelectable
     629            1 : +                    isRowSelected={dlg.values.path == sc.path}
     630            1 : +                    onRowClick={
     631            1 : +                        () => {
     632            1 : +                            setPath(sc.path);
     633            1 : +                            focusFilter();
     634            1 : +                        }
     635              : +                    }
     636              : +                >
     637            1 : +                    <Td>{sc.label}</Td>
     638            1 : +                </Tr>
     639              : +            );
     640            1 : +        }
     641              : +
     642            1 : +        function collection(col: FileChooserCollection) {
     643            1 : +            return (
     644            1 : +                <Tr
     645            1 : +                    key={col.label}
     646            1 : +                    isClickable
     647            1 : +                    isSelectable
     648            1 : +                    isRowSelected={dlg.values.collection == col}
     649            1 : +                    onRowClick={
     650            1 : +                        () => {
     651            1 : +                            setCollection(col);
     652            1 : +                            focusFilter();
     653            1 : +                        }
     654              : +                    }
     655              : +                >
     656            1 : +                    <Td>{col.label}</Td>
     657            1 : +                </Tr>
     658              : +            );
     659            1 : +        }
     660              : +
     661            1 : +        return (
     662            1 : +            <Table variant="compact" borders={false}>
     663            1 : +                <Tbody>
     664            1 : +                    { collection(dlg.values.recent_collection) }
     665            1 : +                    { shortcuts.map(shortcut) }
     666            1 : +                    { shortcut({ label: _("Filesystem"), path: "/" }) }
     667            1 : +                    { dlg.values.collections.map(collection) }
     668            1 : +                </Tbody>
     669            1 : +            </Table>
     670              : +        );
     671            1 : +    }
     672              : +
     673            1 : +    function listing() {
     674            1 : +        function listingBody() {
     675            1 : +            const files = dlg.values.files;
     676              : +
     677            1 : +            if (files == null)
     678            1 : +                return emptyState("", Spinner);
     679              : +
     680            1 : +            if (files instanceof FileError)
     681            1 : +                return emptyState(files.message, FolderIcon);
     682              : +
     683            1 : +            if (files.length == 0) {
     684            1 : +                if (dlg.values.path == "") {
     685            1 : +                    if (!onlyDirectories) {
     686            1 : +                        return emptyState(_("No recent files"), FolderIcon);
     687            1 : +                    } else {
     688            1 : +                        return emptyState(_("No recent directories"), FolderIcon);
     689            1 : +                    }
     690            1 : +                } else if (!onlyDirectories) {
     691            1 : +                    return emptyState(_("Directory is empty"), FolderIcon);
     692            0 : +                } else {
     693            0 : +                    return emptyState(_("Directory has no sub-directories"), FolderIcon);
     694            0 : +                }
     695            1 : +            }
     696              : +
     697            1 : +            const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
     698            1 : +            if (withoutHidden.length == 0)
     699            1 : +                return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
     700              : +
     701            1 : +            const preFiltered = withoutHidden.filter(
     702            1 : +                f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
     703            1 : +            );
     704            1 : +            if (preFiltered.length == 0)
     705            1 : +                return emptyState(_("No matching results"), SearchIcon, 2);
     706              : +
     707            1 : +            const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
     708            1 : +            if (filtered.length == 0)
     709            1 : +                return emptyState(_("No matching results"), SearchIcon, 1);
     710              : +
     711            1 : +            return (
     712            1 : +                <Tbody>
     713              : +                    {
     714            1 : +                        filtered.map(
     715            1 : +                            (f, idx) => {
     716            1 : +                                let name, location;
     717            1 : +                                if (dlg.values.path == "") {
     718            1 : +                                    name = basename(f.name);
     719            1 : +                                    location = dirname(f.name);
     720            1 : +                                } else {
     721            1 : +                                    name = f.name;
     722            1 : +                                }
     723            1 : +                                return (
     724            1 : +                                    <Tr
     725            1 : +                                        className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
     726            1 : +                                        key={idx}
     727            1 : +                                        data-name={name}
     728            1 : +                                        onRowClick={
     729            1 : +                                            () => {
     730            1 : +                                                onSelect(f);
     731            1 : +                                                focusFilter();
     732            1 : +                                            }
     733              : +                                        }
     734            1 : +                                        onDoubleClick={
     735            1 : +                                            event => {
     736            1 : +                                                event.preventDefault();
     737            1 : +                                                onNavigate(f);
     738            1 : +                                                dlg.field("textFilter").set("");
     739            1 : +                                                focusFilter();
     740            1 : +                                            }
     741              : +                                        }
     742            1 : +                                        isClickable
     743              : +                                    >
     744            1 : +                                        <Td>
     745            1 : +                                            {formatIcon(f)}
     746              : +                                            &nbsp;&nbsp;
     747            1 : +                                            {boldify(name, dlg.values.textFilter)}
     748            1 : +                                        </Td>
     749            1 : +                                        { location && <Td>{location}</Td> }
     750            1 : +                                    </Tr>
     751              : +                                );
     752            1 : +                            }
     753            1 : +                        )
     754              : +                    }
     755            1 : +                </Tbody>
     756              : +            );
     757            1 : +        }
     758              : +
     759            1 : +        return (
     760            1 : +            <Table variant="compact" borders={false}>
     761            1 : +                { listingBody() }
     762            1 : +            </Table>
     763              : +        );
     764            1 : +    }
     765              : +
     766            1 : +    return (
     767            1 : +        <Modal
     768            1 : +            isOpen
     769            1 : +            variant="large"
     770            1 : +            position="top"
     771            1 : +            onClose={() => {
     772            1 : +                dlg.cancel(Dialogs.close);
     773            1 : +            }}
     774            1 : +            className="file-chooser"
     775              : +        >
     776            1 : +            <ModalHeader
     777            1 : +                title={title}
     778            1 : +                description={<DialogErrorMessage dialog={dlg} />}
     779            1 : +            />
     780            1 : +            <ModalBody>
     781            1 : +                <div className="file-chooser-body">
     782            1 : +                    <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
     783            1 : +                        { sidebar() }
     784            1 : +                    </div>
     785            1 : +                    <div className="file-chooser-listing-header">
     786            1 : +                        { header() }
     787            1 : +                    </div>
     788            1 : +                    <div className="file-chooser-listing-breadcrumbs">
     789            1 : +                        { breadcrumbs() }
     790            1 : +                    </div>
     791            1 : +                    <div className="file-chooser-listing-body">
     792            1 : +                        { listing() }
     793            1 : +                    </div>
     794            1 : +                </div>
     795            1 : +            </ModalBody>
     796            1 : +            <ModalFooter>
     797            1 : +                <DialogActionButton
     798            1 : +                    dialog={dlg}
     799            1 : +                    isAriaDisabled={selected_path() === null}
     800            1 : +                    action={onAction}
     801            1 : +                    onClose={Dialogs.close}
     802              : +                >
     803            1 : +                    {_("Select")}
     804            1 : +                </DialogActionButton>
     805            1 : +            </ModalFooter>
     806            1 : +        </Modal>
     807              : +    );
     808            1 : +};
     809              : +
     810            1 : +async function getHomeDir(): Promise<string> {
     811            1 : +    if (!cockpit.info.user)
     812            1 : +        await cockpit.init();
     813            1 : +    return cockpit.info.user.home;
     814            1 : +}
     815              : +
     816            1 : +async function getDownloadDir(): Promise<string | null> {
     817            1 : +    try {
     818            1 : +        return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
     819            0 : +    } catch (ex) {
     820            0 : +        console.warn("Can't determine downloads directory", String(ex));
     821            0 : +        return null;
     822            0 : +    }
     823            1 : +}
     824              : +
     825            2 : +const FileChooserButton = ({
     826            2 : +    value,
     827            2 : +    onChoose,
     828            2 : +    props,
     829            2 : +} : {
     830              : +    value: string,
     831              : +    onChoose: (path: string) => void,
     832              : +    props: FileChooserProps,
     833            2 : +}) => {
     834            2 : +    const Dialogs = useDialogs();
     835              : +
     836            2 : +    return (
     837            2 : +        <Button
     838            2 : +            variant="plain"
     839            2 : +            icon={<FolderOpenIcon />}
     840            2 : +            onClick={
     841            1 : +                async () => {
     842            1 : +                    const home = await getHomeDir();
     843            1 : +                    const dd = await getDownloadDir();
     844            1 : +                    const localProps = {
     845            1 : +                        ...props,
     846            1 : +                        shortcuts: [
     847            1 : +                            { label: _("Home"), path: home },
     848            0 : +                            ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
     849            0 : +                            ...props.shortcuts || [],
     850            1 : +                        ]
     851            1 : +                    };
     852              : +
     853            1 : +                    Dialogs.show(
     854            1 : +                        <FileChooserModal
     855            1 : +                            path={value[0] == "/" ? dirname(value) : ""}
     856            1 : +                            onChoose={onChoose}
     857            1 : +                            {...localProps}
     858            1 : +                        />
     859            1 : +                    );
     860            1 : +                }
     861              : +            }
     862            2 : +        />
     863              : +    );
     864            2 : +};
     865              : +
     866            2 : +export const FileChooserInput = ({
     867            2 : +    ouiaId,
     868            2 : +    placeholder = "",
     869            2 : +    value,
     870            2 : +    onChange,
     871            2 : +    fileChooserProps,
     872            2 : +} : {
     873              : +    ouiaId?: undefined | string;
     874              : +    placeholder?: string,
     875              : +    value: string,
     876              : +    onChange: (path: string) => void,
     877              : +    fileChooserProps: FileChooserProps,
     878            2 : +}) => {
     879            2 : +    return (
     880            2 : +        <TextInputGroup data-ouia-component-id={ouiaId}>
     881            2 : +            <TextInputGroupMain
     882            2 : +                value={value}
     883            2 : +                placeholder={placeholder}
     884            1 : +                onChange={(_event, value) => onChange(value)}
     885            2 : +                autoComplete="off"
     886            2 : +            />
     887            2 : +            <TextInputGroupUtilities>
     888            2 : +                <WithDialogs>
     889            2 : +                    <FileChooserButton
     890            2 : +                        value={value}
     891            2 : +                        onChoose={onChange}
     892            2 : +                        props={fileChooserProps}
     893            2 : +                    />
     894            2 : +                </WithDialogs>
     895            2 : +            </TextInputGroupUtilities>
     896            2 : +        </TextInputGroup>
     897              : +    );
     898            2 : +};
     899              : +
     900            2 : +export const DialogFileChooserInput = ({
     901            2 : +    field,
     902            2 : +    label,
     903            2 : +    placeholder = "",
     904            2 : +    explanation,
     905            2 : +    fileChooserProps,
     906            2 : +} : {
     907              : +    field: DialogField<string>,
     908              : +    label: string,
     909              : +    placeholder?: string,
     910              : +    explanation?: React.ReactNode,
     911              : +    fileChooserProps: FileChooserProps,
     912            2 : +}) => {
     913            2 : +    return (
     914            2 : +        <OptionalFormGroup
     915            2 : +            label={label}
     916              : +        >
     917            2 : +            <FileChooserInput
     918            2 : +                ouiaId={field.ouia_id()}
     919            2 : +                placeholder={placeholder}
     920            2 : +                value={field.get()}
     921            1 : +                onChange={val => field.set(val)}
     922            2 : +                fileChooserProps={fileChooserProps}
     923            2 : +            />
     924            2 : +            <DialogHelperText field={field} explanation={explanation} />
     925            2 : +        </OptionalFormGroup>
     926              : +    );
     927            2 : +};
     928              : +
     929            1 : +export function rememberRecent(name: string, type: string, recentKey: string = "recent-files") {
     930            1 : +    const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
     931            1 : +    if (Array.isArray(value)) {
     932            1 : +        const recent = value.filter(is_FileInfo).filter(f => f.name != name);
     933            1 : +        recent.unshift({ name, type });
     934            1 : +        window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
     935            1 : +    }
     936            1 : +}
     937              : diff --git a/pkg/lib/cockpit/dialog.tsx b/pkg/lib/cockpit/dialog.tsx
     938              : index 43c396556..8c56dabf7 100644
     939              : --- a/pkg/lib/cockpit/dialog.tsx
     940              : +++ b/pkg/lib/cockpit/dialog.tsx
     941              : @@ -44,7 +44,7 @@
     942              :          const Dialogs = useDialogs();
     943              :  
     944              :          function validate() {
     945              : -            dlg.value("text").validate(v => {
     946              : +            dlg.field("text").validate(v => {
     947              :                  if (!v)
     948              :                      return "Text can not be empty";
     949              :              });
     950              : @@ -222,6 +222,52 @@
     951              :     array, pass the index of the desired element.  See "dlg.field()"
     952              :     above for more information about handles.
     953              :  
     954              : +   - handle.get_async(debounce, (val, signal) => ...)
     955              : +   - handle.set_async(debounce, (val, signal) => new_val)
     956              : +
     957              : +   These are for running debounced, asynchronous code.  Both functions
     958              : +   will run the given function after "debounce" milliseconds, but only
     959              : +   if the value of the field hasn't changed in the meantime.  The
     960              : +   dialog waits for all asynchronous tasks started by these functions
     961              : +   to be finished before running the action function.  When the dialog
     962              : +   is cancelled, they all get cancelled.
     963              : +
     964              : +   The return value of "handle.set_async" is made the new value of the
     965              : +   field, but only if the value of the field hasn't changed in the
     966              : +   meantime.  There can only be one currently active "set_async" call.
     967              : +   If you call it again before the previous one has finished, that
     968              : +   previous call will be cancelled at that point, just as if the field
     969              : +   value had changed.
     970              : +
     971              : +   The "handle.get_async" function is a slight variation on this. It
     972              : +   is meant to perform asynchronous computations that do not modify
     973              : +   the field value itself, but have some other side effects.  Maybe
     974              : +   they modify multiple other field values or some React state. There
     975              : +   can be more than one call active at a given time. They only get
     976              : +   cancelled when the value of the field changes.
     977              : +
     978              : +   The asynchronous tasks must be careful to only perform their side
     979              : +   effects when they have not been cancelled yet.  This can be done
     980              : +   with the help of their "signal" parameter, which is a standard
     981              : +   AbortSignal.
     982              : +
     983              : +   Because of the way JavaScript works, the asynchronous functions
     984              : +   keep running and they need to voluntarily inspect "signal.aborted"
     985              : +   to figure out when they should stop.  They can also use all other
     986              : +   features of a AbortSignal, of course, such as setting
     987              : +   "signal.onabort", adding event listeners, etc.
     988              : +
     989              : +   As an example, here is how you might implement set_async on top of
     990              : +   get_async:
     991              : +
     992              : +      function set_async(handle, debounce, func) {
     993              : +          handle.get_async(debounce, (val, signal) => {
     994              : +              const new_val = await func(val, signal);
     995              : +              if (!signal.aborted)
     996              : +                  handle.set(new_val);
     997              : +          })
     998              : +      }
     999              : +
    1000              :     - handle.at(witness)
    1001              :  
    1002              :     Get a handle with a narrowed type for "handle".  The new handle
    1003              : @@ -247,7 +293,8 @@
    1004              :     It is important to use this function instead of just "handle.set()"
    1005              :     with an appropriately modified array. By using this function, the
    1006              :     plumbing is able to keep its internal state in synch, which is
    1007              : -   especially important for asynchronous validation functions.
    1008              : +   especially important for asynchronous validation and update
    1009              : +   functions.
    1010              :  
    1011              :     However, it is okay to just replace an array with a different
    1012              :     array, so you are not strictly required to use this function. But
    1013              : @@ -285,23 +332,30 @@
    1014              :  
    1015              :     - dlg.run_action(func)
    1016              :  
    1017              : -   Performs input validation (if necessary) and if that was
    1018              : -   successful, calls "func" and puts the dialog into a "busy" state
    1019              : -   while it runs. When "func" throws an error, it is caught and stored
    1020              : -   in "dlg.error".
    1021              : +   Waits for all asynchronous updates and input validation to be done
    1022              : +   and if that was successful, calls "func" and puts the dialog into a
    1023              : +   "busy" state while it runs. When "func" throws an error, it is
    1024              : +   caught and stored in "dlg.error".
    1025              :  
    1026              :     "dlg.run_action" returns true when validation has passed and "func"
    1027              :     has completed without throwing an error.
    1028              :  
    1029              : -   All state changes via "field.set()" are denied while
    1030              : -   "dlg.run_action" is running. This is done to prevent the user from
    1031              : -   interacting with the dialog while an action runs. But there is
    1032              : -   nothing fundamentally wrong with programmatically changing dialog
    1033              : -   state as part of an action. If you want to do that, write code like
    1034              : +   All state changes via "field.set()" are denied while "func" is
    1035              : +   running. This is done to prevent the user from interacting with the
    1036              : +   dialog while an action runs. But there is nothing fundamentally
    1037              : +   wrong with programmatically changing dialog state as part of an
    1038              : +   action. If you want to do that, write code like
    1039              :  
    1040              :       if (dlg.run_action(...))
    1041              :         dlg.field("xxx").set(...)
    1042              :  
    1043              : +   - dlg.cancel(onClose)
    1044              : +
    1045              : +   Does whatever should happen when the "Cancel" button is
    1046              : +   clicked. When an action is running, it will call the "cancel
    1047              : +   function" (see below).  Otherwise all validation and update tasks
    1048              : +   are cancelled and the dialog is closed by calling "onClose".
    1049              : +
    1050              :     - dlg.set_cancel(func)
    1051              :  
    1052              :     Arranges for "func" to be called when the cancel button is clicked.
    1053              : @@ -312,13 +366,12 @@
    1054              :     within "dlg.run_action", the cancel function is automatically
    1055              :     reset.
    1056              :  
    1057              : -   Let's now finally talk about input validation.
    1058              : +   VALIDATION
    1059              :  
    1060              :     Input validation is done by a single, central function for the
    1061              :     whole dialog.  This has been done so that there is a central place
    1062              :     that establishes the "shape" of the dialog values. This is
    1063              : -   important for dialogs that have expander areas or other optional
    1064              : -   things.
    1065              : +   important for dialogs that have optional parts.
    1066              :  
    1067              :     If such an optional part of the values has failed validation
    1068              :     earlier, but has subsequently been removed from the dialog by the
    1069              : @@ -332,8 +385,8 @@
    1070              :  
    1071              :     The formal job of the validation function is to call the "validate"
    1072              :     method (or "validate_async") of all relevant dialog value handles.
    1073              : -   If and only if the render function instantiates a component for a
    1074              : -   dialog value, should the validate function visit it.
    1075              : +   If and only if a validation failure of a field should prevent
    1076              : +   running the action function, should the validate function visit it.
    1077              :  
    1078              :     - handle.validate(v => ...)
    1079              :  
    1080              : @@ -342,15 +395,12 @@
    1081              :     "undefined". If it fails, the function should return a string with
    1082              :     the appropriate message. This message will be available from the
    1083              :     "handle.validation_text" method and should be shown by the React
    1084              : -   component for this value, of course.
    1085              : +   component for this value, of course. Returning an error here will
    1086              : +   also disable the action buttons.
    1087              :  
    1088              :     The "v => ..." function is only called when necessary, when the
    1089              :     value has actually changed.
    1090              :  
    1091              : -   The "v => ..." function should not make any modifications to
    1092              : -   anything involved in the dialog. Specifically, it should not call
    1093              : -   "set()" on any value handle.
    1094              : -
    1095              :     A validation function can also return an object with validation
    1096              :     errors for its sub-fields.  This is useful if multiple fields need
    1097              :     to be validated together.  Consider this example:
    1098              : @@ -363,10 +413,10 @@
    1099              :     If your validation function needs to communicate out-of-band with
    1100              :     your action function (maybe to pass the results of some expensive
    1101              :     operations that you don't want to repeat in your action function),
    1102              : -   then you need to find some other way. Maybe with a memoized
    1103              : -   function or an explicit cache.
    1104              : +   then you can modify field values via calls to "handle.set". (Be
    1105              : +   careful not to create endless validation loops!)
    1106              :  
    1107              : -   - handle.validate_async(debounce, async v >= ...)
    1108              : +   - handle.validate_async(debounce, async (v, task) >= ...)
    1109              :  
    1110              :     Calls the given async function "debounce" milliseconds after the
    1111              :     value represented by the handle has last been changed. (Or
    1112              : @@ -377,6 +427,60 @@
    1113              :     See the documentation for "handle.validate" above for more rules
    1114              :     that apply to validation functions.
    1115              :  
    1116              : +   UPDATES
    1117              : +
    1118              : +   Sometimes dialog values need to be changed in reaction to other
    1119              : +   changes.  For example, when the user selects a ISO for creating a
    1120              : +   new virtual machine, you might want to run some code that detects
    1121              : +   the OS on that ISO and then adapts the rest of the dialog to the
    1122              : +   minimum storage requirements of the OS.  Sometimes you can do
    1123              : +   everything at render time, but sometimes you might want to run some
    1124              : +   code as part of the event handler for the user action, and
    1125              : +   sometimes you need to run asynchornous code.
    1126              : +
    1127              : +   (Don't use useEffect, please, just stick the code into the event
    1128              : +   handler.)
    1129              : +
    1130              : +   It's okay and simplest to just put that code right next to the call
    1131              : +   to "handler.set()".  If that call is in a porcelain component (as
    1132              : +   it probably often will be), you can pass a "update_func" when
    1133              : +   creating the handle for that porcelain component with
    1134              : +   "handler.sub()" or "dialog.field()".  For example:
    1135              : +
    1136              : +       function on_plate_change(val: string) {
    1137              : +           console.log("NEW LICENSE PLATE", val);
    1138              : +       }
    1139              : +
    1140              : +       return (
    1141              : +           <DialogTextInput
    1142              : +               label="License plate number"
    1143              : +               field={dlg.field("plate", on_plate_change)}
    1144              : +           />
    1145              : +       );
    1146              : +
    1147              : +   The function "on_plate_change" will be called whenever the user
    1148              : +   changes the "plate" field via the DialogTextInput.  The
    1149              : +   "on_plate_change" function will not be called when the "plate" is
    1150              : +   changed in other places.  If that should happen, you have to
    1151              : +   arrange for it explicitly.
    1152              : +
    1153              : +   Functions like "on_plate_change" can and should modify the dialog
    1154              : +   fields via calls to "handle.set()".
    1155              : +
    1156              : +   If you want to run asynchronous code, you can do so with
    1157              : +   "handle.set_async()" or "handle.get_async()".  For example, if you
    1158              : +   want to asynchronously fetch the car model for a given license
    1159              : +   plate from a database, you can do it like this:
    1160              : +
    1161              : +       function on_plate_change(val: string) {
    1162              : +           dlg.field("model").set_async(1000, async () => await fetch_model(val));
    1163              : +       }
    1164              : +
    1165              : +   When arrays are involved, dialog fields can move around while your
    1166              : +   asynchronous update function runs.  To help with this, handles will
    1167              : +   keep referring to the same field even if it moves around in its
    1168              : +   array.
    1169              : +
    1170              :     TESTING
    1171              :  
    1172              :     Our automated tests will want to drive the dialogs created by this
    1173              : @@ -385,28 +489,34 @@
    1174              :     attributes. The code that instantiates an element can get suitable
    1175              :     IDs for dialog value handles with the following function:
    1176              :  
    1177              : -   - handle.id(tag)
    1178              : +   - handle.ouia_id(tag)
    1179              :  
    1180              : -   This will return a unique and predictable string for the value
    1181              : -   handle that will also include "tag". This is suitable for the "id"
    1182              : -   attribute of DOM elements associated with "value".  The "tag"
    1183              : -   parameter can be used to generate multiple IDs if a component has
    1184              : -   multiple interesting DOM elements.  The "tag" parameter defaults to
    1185              : -   "field", see below.
    1186              : +   This will return a predictable string for the value handle.  This
    1187              : +   is suitable for the "data-ouia-component-id" attribute of DOM
    1188              : +   elements associated with "handle".  The "tag" parameter can be used
    1189              : +   to generate multiple IDs if a component has multiple interesting
    1190              : +   DOM elements.  The "tag" parameter defaults to "field", see below.
    1191              :  
    1192              :     There is a support library for use by the tests that can generate
    1193              :     the same IDs, and there are also some guidelines for how to use
    1194              :     these IDs:
    1195              :  
    1196              : -   - The main input element (text input, form select, ...) should use
    1197              : -     the "field" tag.
    1198              : +     - The main input element (text input, form select, ...) should use
    1199              : +       the "field" tag.
    1200              :  
    1201              : -   - The helper text should use the "helper-text" tag.
    1202              : +     - The helper text should use the "helper-text" tag.
    1203              :  
    1204              : -   - A set of radio buttons should use a different tag for each
    1205              : -     button. Whatever makes sense in the specific case.
    1206              : +     - A set of radio buttons should use a different tag for each
    1207              : +       button. Whatever makes sense in the specific case.
    1208              :  
    1209              : -   - ...
    1210              : +     - ...
    1211              : +
    1212              : +   - handle.random_id()
    1213              : +
    1214              : +   Returns a string that is suitable for the "id" attribute of a DOM
    1215              : +   element.  All handles for the same dialog field will return the
    1216              : +   same id.  These ids are unique across all dialogs so they also work
    1217              : +   when multiple dialogs are displayed simultaneously.
    1218              :  
    1219              :     PORCELAIN GALLERY
    1220              :  
    1221              : @@ -492,126 +602,6 @@
    1222              :    data model. A simple case is selecting from an array of strings. In
    1223              :    that case you can omit the "option_label" function.
    1224              :  
    1225              : -  WRITING COMPLEX PORCELAIN COMPONENTS
    1226              : -
    1227              : -  Here is a pattern that you might want to follow when writing
    1228              : -  complicated components. Even if they are not meant to be reused
    1229              : -  much, it pays of to try to encapsulate their behavior.
    1230              : -
    1231              : -  Let's write a component for two level selection.  Parameter is
    1232              : -  something like
    1233              : -
    1234              : -     {
    1235              : -       "Fruit": [ "Apple", "Banana" ],
    1236              : -       "Bread": [ "Toast", "Rye" ],
    1237              : -       "Meat": [ "Chicken", "Pork" ],
    1238              : -     }
    1239              : -
    1240              : -  and there will be two dropdowns in the dialog, one for selecting
    1241              : -  between "Fruit", "Bread", and "Meat"; and one for selecting "Apple"
    1242              : -  or "Banana" when the first is "Fruit", etc.
    1243              : -
    1244              : -  First, declare the type of the value that the component works with.
    1245              : -  It should store everything needed by the component, to simplify
    1246              : -  initialization and validation.
    1247              : -
    1248              : -    export interface TwoLevelSelectValue {
    1249              : -      first: string;
    1250              : -      second: string;
    1251              : -
    1252              : -      _firsts: string[],
    1253              : -      _options: Record<string, string[]>,
    1254              : -    }
    1255              : -
    1256              : -  Write a "init" function to create such a value:
    1257              : -
    1258              : -    export function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelValue {
    1259              : -      const _firsts = Object.keys(options);
    1260              : -      const _seconds = options[_firsts[0]];
    1261              : -
    1262              : -      return {
    1263              : -        first: _firsts[0],
    1264              : -        second: _seconds[0],
    1265              : -
    1266              : -        _firsts,
    1267              : -        _seconds,
    1268              : -        _options: options,
    1269              : -      };
    1270              : -    }
    1271              : -
    1272              : -  And the component itself:
    1273              : -
    1274              : -    export const TwoLevelSelect = ({ field } : { field: DialogField<TwoLevelSelectValue> }) => {
    1275              : -      const { _firsts, _seconds, _options } = field.get();
    1276              : -
    1277              : -      function update_first(f: string) {
    1278              : -        const _seconds = _options[f];
    1279              : -        value.sub("second").set(_seconds[0]);
    1280              : -        value.sub("_seconds").set(_seconds);
    1281              : -      }
    1282              : -
    1283              : -      return (
    1284              : -        <>
    1285              : -          <DialogDropdownSelectObject
    1286              : -            label="First"
    1287              : -            field={field.sub("first", update_first)}
    1288              : -            options={_firsts}
    1289              : -          />
    1290              : -          <DialogDropdownSelectObject
    1291              : -            label="Second"
    1292              : -            field={field.sub("second")}
    1293              : -            options={_seconds}
    1294              : -          />
    1295              : -        </>
    1296              : -      );
    1297              : -    }
    1298              : -
    1299              : -  It would be used in a dialog like this:
    1300              : -
    1301              : -    interface DialogValues {
    1302              : -      food: TwoLevelSelectValue;
    1303              : -    }
    1304              : -
    1305              : -    function init() {
    1306              : -      return {
    1307              : -        food: init_TwoLevelSelect({ "Fruit": [ "Apple", "Banana" ], "Bread": [ "Toast", "Rye" ], "Meat": [ "Chicken", "Pork" ] }),
    1308              : -      }
    1309              : -    }
    1310              : -
    1311              : -    const dlg = useDialogState(init);
    1312              : -
    1313              : -    return (
    1314              : -      ...
    1315              : -      <TwoLevelSelect field={dlg.field("food")} />
    1316              : -      ...
    1317              : -    );
    1318              : -
    1319              : -  Here is a pattern for handling types that include alternatives, such
    1320              : -  as "TwoLevelSelectValue | string".  This could be used to encode
    1321              : -  either the state for a working TwoLevelSelect component, or an
    1322              : -  excuse message that explains why it can't work.
    1323              : -
    1324              : -    function init_TwoLevelSelect(options: Record<string, string[]>): TwoLevelSelectValue | string {
    1325              : -      if (Object.keys(options).length == 0)
    1326              : -        return _("Nothing to select.");
    1327              : -
    1328              : -      return { ... };
    1329              : -    }
    1330              : -
    1331              : -    export const TwoLevel = ({ field } : { field: DialogField<TwoLevelValue | string> }) => {
    1332              : -      const val = field.get();
    1333              : -      if (typeof val == "string")
    1334              : -          return null;
    1335              : -
    1336              : -      const tls_field = field.at(val);
    1337              : -
    1338              : -      const { _firsts, _seconds, _options } = tls_field.get();
    1339              : -      ...
    1340              : -    }
    1341              : -
    1342              : -  Note the use of the "field.at()" function to get a handle for a
    1343              : -  TwoLevelSelectValue that can be used to access the "first" sub
    1344              : -  value, etc.
    1345              :   */
    1346              :  
    1347              :  import React, { useState } from "react";
    1348              : @@ -691,28 +681,36 @@ export type DialogValidationResult<T> = (
    1349              :      : undefined | string | { ""?: undefined | string }
    1350              :  );
    1351              :  
    1352            4 : +function state_path(state: DialogFieldState): string {
    1353            4 : +    const p = state.parent ? state_path(state.parent) : "";
    1354            4 : +    const t = String(state.tag);
    1355            2 : +    return p ? `${p}.${t}` : t;
    1356            4 : +}
    1357              : +
    1358           39 : +let next_global_dialog_id: number = 0;
    1359              : +
    1360              :  export class DialogField<T> {
    1361              :      /* eslint-disable no-use-before-define */
    1362              :      #dialog: DialogState<unknown>;
    1363            4 : +    #state: DialogFieldState;
    1364              :      /* eslint-enable */
    1365              :      #getter: () => T;
    1366              :      #setter: (val: T) => void;
    1367              : -    #path: string;
    1368              :  
    1369              :      constructor(
    1370              :          dialog: DialogState<unknown>,
    1371            4 : +        state: DialogFieldState,
    1372              :          getter: () => T,
    1373              :          setter: (val: T) => void,
    1374              : -        path: string
    1375              :      ) {
    1376              :          this.#dialog = dialog;
    1377            4 : +        this.#state = state;
    1378              :          this.#getter = getter;
    1379              :          this.#setter = setter;
    1380              : -        this.#path = path;
    1381              :      }
    1382              :  
    1383              :      validation_text(): string | undefined {
    1384              : -        return this.#dialog._get_validation(this.#path);
    1385            4 : +        return this.#state.validation_text;
    1386              :      }
    1387              :  
    1388              :      get(): T {
    1389              : @@ -720,11 +718,18 @@ export class DialogField<T> {
    1390              :      }
    1391              :  
    1392              :      set(val: T): void {
    1393            4 : +        this.#dialog._abort_state_tasks(this.#state, true);
    1394              :          this.#setter(val);
    1395              :      }
    1396              :  
    1397              : -    id(tag: string = "field"): string {
    1398              : -        return "dialog-" + tag + "-" + this.#path;
    1399            4 : +    ouia_id(tag: string = "field"): string {
    1400            4 : +        return "dialog-" + tag + "-" + state_path(this.#state);
    1401            4 : +    }
    1402              : +
    1403            4 : +    random_id(): string {
    1404            4 : +        if (this.#state.id == null)
    1405            4 : +            this.#state.id = "dialog-" + String(++next_global_dialog_id);
    1406            4 : +        return this.#state.id;
    1407              :      }
    1408              :  
    1409              :      map<X>(func: (val: DialogField<ArrayElement<T>>, index: number) => X): X[] {
    1410              : @@ -745,56 +750,83 @@ export class DialogField<T> {
    1411              :      remove(index: number) {
    1412              :          const val = this.get();
    1413              :          if (Array.isArray(val)) {
    1414              : -            for (let j = index; j < val.length - 1; j++)
    1415              : -                this.#dialog._rename_validation_state(this.#path, j + 1, j);
    1416              : -            this.set(toSpliced(val, index, 1) as T);
    1417            1 : +            const sub = this.#state.sub.get(index);
    1418            1 : +            if (sub) {
    1419            1 : +                this.#dialog._abort_state_tasks(sub);
    1420            1 : +                sub.tag = -1;
    1421            1 : +            }
    1422            1 : +            for (let j = index; j < val.length - 1; j++) {
    1423            1 : +                const sub = this.#state.sub.get(j + 1);
    1424            1 : +                if (sub) {
    1425            1 : +                    sub.tag = j;
    1426            1 : +                    this.#state.sub.set(j, sub);
    1427            1 : +                }
    1428            1 : +            }
    1429            1 : +            this.#state.sub.delete(val.length - 1);
    1430            1 : +            this.#setter(toSpliced(val, index, 1) as T);
    1431              :          }
    1432              :      }
    1433              :  
    1434              :      add(item: ArrayElement<T>) {
    1435              :          const val = this.get();
    1436              :          if (Array.isArray(val)) {
    1437              : -            this.set(val.concat(item) as T);
    1438            1 : +            this.#setter(val.concat(item) as T);
    1439              :          }
    1440              :      }
    1441              :  
    1442              :      sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
    1443            4 : +        const sub = this.#dialog._get_sub_state(this.#state, tag);
    1444              :          return new DialogField<T[K]>(
    1445              :              this.#dialog,
    1446              : -            () => this.get()[tag],
    1447            4 : +            sub,
    1448            4 : +            () => {
    1449            4 : +                const container = this.get();
    1450            2 : +                if (Array.isArray(container) && typeof sub.tag == "number") {
    1451            2 : +                    return container[sub.tag];
    1452            2 : +                } else {
    1453            4 : +                    return container[tag];
    1454            4 : +                }
    1455            4 : +            },
    1456              :              (val) => {
    1457              :                  const container = this.get();
    1458              : -                if (Array.isArray(container) && typeof tag == "number")
    1459              : -                    this.#setter(toSpliced(container, tag, 1, val) as T);
    1460              : -                else
    1461            2 : +                if (Array.isArray(container) && typeof sub.tag == "number") {
    1462            2 : +                    this.#setter(toSpliced(container, sub.tag, 1, val) as T);
    1463            2 : +                } else {
    1464              :                      this.#setter({ ...container, [tag]: val });
    1465            4 : +                }
    1466              :                  if (update_func)
    1467              :                      update_func(val);
    1468              :              },
    1469              : -            this.#path ? this.#path + "." + String(tag) : String(tag)
    1470              :          );
    1471              :      }
    1472              :  
    1473              :      at<TT extends T>(witness: TT): DialogField<TT> {
    1474              :          cockpit.assert(Object.is(witness, this.get()));
    1475              : -        return new DialogField<TT>(
    1476              : -            this.#dialog,
    1477              : -            () => this.get() as TT,
    1478              : -            (val) => {
    1479              : -                this.#setter(val);
    1480              : -            },
    1481              : -            this.#path,
    1482              : -        );
    1483            1 : +        return this as unknown as DialogField<TT>;
    1484              :      }
    1485              :  
    1486              :      validate(func: (val: T) => DialogValidationResult<T>): void {
    1487              :          const val = this.get();
    1488              : -        this.#dialog._validate_value(this.#path, val, () => func(val));
    1489            3 : +        this.#dialog._validate_value(this.#state, val, () => func(val));
    1490              :      }
    1491              :  
    1492              : -    validate_async(debounce: number, func: (val: T) => Promise<DialogValidationResult<T>>): void {
    1493            1 : +    validate_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<DialogValidationResult<T>>): void {
    1494              :          const val = this.get();
    1495              : -        this.#dialog._validate_value_async(this.#path, val, debounce, () => func(val));
    1496            1 : +        this.#dialog._validate_value_async(this.#state, val, debounce, signal => func(val, signal));
    1497            1 : +    }
    1498              : +
    1499            2 : +    set_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<T>): void {
    1500            2 : +        const val = this.get();
    1501            2 : +        this.#dialog._set_value_async(this.#state, debounce, async signal => {
    1502            2 : +            const new_val = await func(val, signal);
    1503            2 : +            if (!signal.aborted)
    1504            2 : +                this.set(new_val);
    1505            2 : +        });
    1506            2 : +    }
    1507              : +
    1508            1 : +    get_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<void>): void {
    1509            1 : +        const val = this.get();
    1510            1 : +        this.#dialog._get_value_async(this.#state, debounce, signal => func(val, signal));
    1511              :      }
    1512              :  }
    1513              :  
    1514              : @@ -807,13 +839,98 @@ function get_validation_result_own_string(result: unknown): string | undefined {
    1515              :          return undefined;
    1516              :  }
    1517              :  
    1518              : -interface DialogValidationState {
    1519              : -    path: string;
    1520           39 : +export class DialogTask {
    1521            2 : +    #name: string;
    1522            2 : +    #timeout_id: number = 0;
    1523            2 : +    #promise: Promise<void> | null = null;
    1524            2 : +    #start: () => void;
    1525            2 : +    #done: (task: DialogTask) => void;
    1526            2 : +    #controller: AbortController;
    1527              : +
    1528            2 : +    constructor(
    1529            2 : +        name: string,
    1530            2 : +        debounce: number,
    1531            2 : +        func: (task: DialogTask) => Promise<void>,
    1532            2 : +        done: (task: DialogTask) => void,
    1533            2 : +    ) {
    1534            2 : +        this.#name = name;
    1535            2 : +        this.#done = done;
    1536            2 : +        this.#start = () => {
    1537            2 : +            debug("starting task", this.#name);
    1538            2 : +            cockpit.assert(!this.#controller.signal.aborted);
    1539            2 : +            this.#promise = func(this);
    1540            2 : +            this.#promise.finally(() => {
    1541            2 : +                debug("task done", this.#name);
    1542            2 : +                done(this);
    1543            2 : +            });
    1544            2 : +        };
    1545            2 : +        this.#timeout_id = window.setTimeout(this.#start, debounce);
    1546            2 : +        this.#controller = new AbortController();
    1547            2 : +        debug("creating task", this.#name, debounce);
    1548            2 : +    }
    1549              : +
    1550            1 : +    start_now() {
    1551            1 : +        if (!this.#promise && !this.#controller.signal.aborted) {
    1552            1 : +            debug("skipping debounce of task", this.#name);
    1553            1 : +            window.clearTimeout(this.#timeout_id);
    1554            1 : +            this.#start();
    1555            1 : +        }
    1556            1 : +    }
    1557              : +
    1558            1 : +    async wait() {
    1559              : +        // Waiting is only allowed for tasks that have actually been started.
    1560            1 : +        cockpit.assert(this.#promise);
    1561            1 : +        debug("waiting for task", this.#name);
    1562            1 : +        await this.#promise;
    1563            1 : +    }
    1564              : +
    1565            2 : +    get_abort_signal() {
    1566            2 : +        return this.#controller.signal;
    1567            2 : +    }
    1568              : +
    1569            2 : +    abort() {
    1570            2 : +        debug("aborting task", this.#name);
    1571            2 : +        window.clearTimeout(this.#timeout_id);
    1572            2 : +        this.#controller.abort();
    1573            2 : +        if (!this.#promise) {
    1574            2 : +            debug("aborted task done", this.#name);
    1575            2 : +            this.#done(this);
    1576            2 : +        }
    1577            2 : +    }
    1578           39 : +}
    1579              : +
    1580              : +/* A DialogFieldState object holds all state for a field.  Unlike
    1581              : +   handles, there is at most one of these objects for each field, and
    1582              : +   each handle for a given field refers to the exact same
    1583              : +   DialogFieldState object.
    1584              : +
    1585              : +   DialogFieldStates are created on-demand and will over time form a
    1586              : +   tree (expressed via "parent" and the children in the "sub" map)
    1587              : +   that corresponds to the nesting of the dialog values.
    1588              : +
    1589              : +   The "tag" is used to access the dialog value.  A handle constructed
    1590              : +   via dlg.field("name") will point to a state object with tag "name",
    1591              : +   for example, and calling handle.get() will return
    1592              : +   dlg.values["name"].
    1593              : +
    1594              : +   Other members of a DialogFieldState relate to validation and
    1595              : +   asynchronous updates.
    1596              : + */
    1597              : +
    1598              : +interface DialogFieldState {
    1599              : +    parent: DialogFieldState | null,
    1600              : +    tag: string | number | symbol;
    1601              : +    sub: Map<string | number | symbol, DialogFieldState>;
    1602              : +    id: null | string;
    1603              : +    // validation
    1604              : +    relevant: boolean;
    1605              : +    validation_text: string | undefined;
    1606              :      cached_value: unknown;
    1607              :      cached_result: unknown;
    1608              : -    timeout_id: number;
    1609              : -    promise: Promise<void> | undefined;
    1610              : -    round_id: unknown;
    1611              : +    validation_task: DialogTask | null;
    1612              : +    // updates
    1613              : +    update_task: DialogTask | null;
    1614              : +    update_tasks: Set<DialogTask>;
    1615              :  }
    1616              :  
    1617              :  interface DialogStateEvents {
    1618              : @@ -826,139 +943,246 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
    1619              :      busy: boolean = false;
    1620              :      actions_disabled: boolean = false;
    1621              :      cancel_disabled: boolean = false;
    1622              : -    cancel_function: (() => void) | null = null;
    1623              :  
    1624              :      error: unknown = null;
    1625              :  
    1626              :      #validation_failed: boolean = false;
    1627              :      #online_validation: boolean = false;
    1628              :      #action_running: boolean = false;
    1629              : -    #validation: Record<string, string | undefined> = { };
    1630              : -    #validation_state: Record<string, DialogValidationState> = { };
    1631            4 : +    #block_updates: boolean = false;
    1632            4 : +    #cancel_function: (() => void) | null = null;
    1633              : +
    1634            4 : +    #top_state: DialogFieldState;
    1635              :  
    1636              :      /* eslint-disable no-use-before-define */
    1637              :      #validate_callback: undefined | ((dlg: DialogState<V>) => void);
    1638              :      /* eslint-enable */
    1639              :  
    1640              :      constructor(init: V, validate: undefined | ((dlg: DialogState<V>) => void)) {
    1641            4 : +        debug("open");
    1642              :          super();
    1643              :          this.#validate_callback = validate;
    1644              :          this.values = init;
    1645            4 : +        this.#top_state = {
    1646            4 : +            parent: null,
    1647            4 : +            tag: "",
    1648            4 : +            sub: new Map(),
    1649            4 : +            id: null,
    1650            4 : +            relevant: false,
    1651            4 : +            validation_text: undefined,
    1652            4 : +            cached_value: undefined,
    1653            4 : +            cached_result: undefined,
    1654            4 : +            validation_task: null,
    1655            4 : +            update_task: null,
    1656            4 : +            update_tasks: new Set(),
    1657            4 : +        };
    1658              :      }
    1659              :  
    1660              :      #update() {
    1661              :          this.busy = this.#action_running;
    1662              :          this.actions_disabled = this.#action_running || this.#validation_failed;
    1663              : -        this.cancel_disabled = this.#action_running && !this.cancel_function;
    1664            4 : +        this.cancel_disabled = this.#action_running && !this.#cancel_function;
    1665              :          this.emit("changed");
    1666              :      }
    1667              :  
    1668              : +    /* FIELD STATES
    1669              : +
    1670              : +       During validation and asynchronous updates, a lot is going on.
    1671              : +
    1672              : +       We use a DialogFieldState object to keep the necessary
    1673              : +       state for that, such as cached results, and timeouts and
    1674              : +       promises.
    1675              : +
    1676              : +       These state objects keep their identity when arrays elements
    1677              : +       move around.  Their "index" field will be changed when that
    1678              : +       happens.
    1679              : +     */
    1680              : +
    1681            4 : +    _get_sub_state(state: DialogFieldState, tag: string | number | symbol): DialogFieldState {
    1682            4 : +        let sub = state.sub.get(tag);
    1683            4 : +        if (!sub) {
    1684            4 : +            sub = {
    1685            4 : +                parent: state,
    1686            4 : +                tag,
    1687            4 : +                sub: new Map(),
    1688            4 : +                id: null,
    1689            4 : +                relevant: false,
    1690            4 : +                validation_text: undefined,
    1691            4 : +                cached_value: undefined,
    1692            4 : +                cached_result: undefined,
    1693            4 : +                validation_task: null,
    1694            4 : +                update_task: null,
    1695            4 : +                update_tasks: new Set(),
    1696            4 : +            };
    1697            4 : +            state.sub.set(tag, sub);
    1698            4 : +        }
    1699            4 : +        return sub;
    1700            4 : +    }
    1701              : +
    1702            4 : +    _for_each_field_state(func: (state: DialogFieldState) => void) {
    1703            4 : +        function visit(state: DialogFieldState) {
    1704            4 : +            func(state);
    1705            4 : +            for (const sub of state.sub.values())
    1706            4 : +                visit(sub);
    1707            4 : +        }
    1708            4 : +        visit(this.#top_state);
    1709            4 : +    }
    1710              : +
    1711            4 : +    async _for_each_field_state_async(func: (state: DialogFieldState) => Promise<void>) {
    1712            4 : +        async function visit(state: DialogFieldState) {
    1713            4 : +            await func(state);
    1714            4 : +            for (const sub of state.sub.values())
    1715            4 : +                await visit(sub);
    1716            4 : +        }
    1717            4 : +        await visit(this.#top_state);
    1718            4 : +    }
    1719              : +
    1720              : +    /* TASKS
    1721              : +
    1722              : +       Tasks are a little abstraction that runs a asynchronous
    1723              : +       function after a debounce timeout.  Before running the action
    1724              : +       function, we need to wait for them all to finish.
    1725              : +     */
    1726              : +
    1727            4 : +    async _run_all_tasks_now() {
    1728            4 : +        let awaited: boolean = false;
    1729            4 : +        do {
    1730            4 : +            this._for_each_field_state(state => {
    1731            4 : +                if (state.validation_task)
    1732            2 : +                    state.validation_task.start_now();
    1733            4 : +                if (state.update_task)
    1734            2 : +                    state.update_task.start_now();
    1735            4 : +                for (const task of state.update_tasks.values())
    1736            2 : +                    task.start_now();
    1737            4 : +            });
    1738              : +
    1739            4 : +            awaited = false;
    1740            4 : +            await this._for_each_field_state_async(async state => {
    1741            2 : +                if (state.validation_task) {
    1742            2 : +                    await state.validation_task.wait();
    1743            2 : +                    awaited = true;
    1744            2 : +                }
    1745            2 : +                if (state.update_task) {
    1746            2 : +                    await state.update_task.wait();
    1747            2 : +                    awaited = true;
    1748            2 : +                }
    1749            2 : +                for (const task of state.update_tasks.values()) {
    1750            2 : +                    await task.wait();
    1751            2 : +                    awaited = true;
    1752            2 : +                }
    1753            4 : +            });
    1754            4 : +        } while (awaited);
    1755            4 : +    }
    1756              : +
    1757            4 : +    _abort_state_tasks(state: DialogFieldState, only_updates: boolean = false) {
    1758            4 : +        debug("cancelling state tasks", state_path(state), only_updates);
    1759            2 : +        if (state.validation_task && !only_updates)
    1760            2 : +            state.validation_task.abort();
    1761            4 : +        if (state.update_task)
    1762            3 : +            state.update_task.abort();
    1763            4 : +        for (const task of state.update_tasks.values())
    1764            2 : +            task.abort();
    1765            4 : +        for (const sub of state.sub.values())
    1766            3 : +            this._abort_state_tasks(sub, only_updates);
    1767            4 : +    }
    1768              : +
    1769              :      /* VALIDATION
    1770              : -     */
    1771              :  
    1772              : -    /* Validation is started by calling the #trigger_validation
    1773              : -       method. This will reset all validation errors and then call the
    1774              : -       provided "validate" callback, which in turn will (eventually
    1775              : -       but synchronously) call the "_validate_value" or
    1776              : -       "_validate_value_async" methods of all relevant value paths.
    1777              : -       Those functions will eventually call #set_validation to install
    1778              : -       the validation results in the fresh #validation object created
    1779              : -       by #trigger_validation.
    1780              : +       Validation is started by calling the #trigger_validation
    1781              : +       method. This will reset all validation errors and mark all
    1782              : +       fields as "irrelevant". Then it calls the provided "validate"
    1783              : +       callback, which in turn will (eventually but synchronously)
    1784              : +       call the "_validate_value" or "_validate_value_async" methods
    1785              : +       of all relevant value paths.  Those functions will mark their
    1786              : +       fields as relevant and eventually call #set_validation to
    1787              : +       install the validation results in the field states.
    1788              : +
    1789              : +       After this, all irrelevant asynchronous validation tasks are
    1790              : +       cancelled.
    1791              :       */
    1792              :  
    1793            4 : +    #validation_needed: boolean = false;
    1794            4 : +    #validation_running: boolean = false;
    1795              : +
    1796              :      #trigger_validation(): void {
    1797              :          debug("trigger validation");
    1798              :          if (!this.#validate_callback)
    1799              :              return;
    1800              : -        this.#validation = { };
    1801              : -        this.#validation_failed = false;
    1802              : -        this.#validate_callback(this);
    1803              : +
    1804            3 : +        this.#validation_needed = true;
    1805            2 : +        if (this.#validation_running) {
    1806            2 : +            debug("validation postponed");
    1807            2 : +            return;
    1808            2 : +        }
    1809              : +
    1810            3 : +        this.#validation_running = true;
    1811            3 : +        while (this.#validation_needed) {
    1812            3 : +            debug("running validation");
    1813            3 : +            this.#validation_needed = false;
    1814            3 : +            this.#validation_failed = false;
    1815            3 : +            this._for_each_field_state(state => {
    1816            3 : +                state.relevant = false;
    1817            3 : +                state.validation_text = undefined;
    1818            3 : +            });
    1819            3 : +            this.#validate_callback(this);
    1820            3 : +            this._for_each_field_state(state => {
    1821            2 : +                if (!state.relevant && state.validation_task) {
    1822            2 : +                    debug("aborting irrelevant validation task", state_path(state));
    1823            2 : +                    state.validation_task.abort();
    1824            2 : +                }
    1825            3 : +            });
    1826            3 : +        }
    1827            3 : +        this.#validation_running = false;
    1828              : +
    1829              :          this.#update();
    1830              :      }
    1831              :  
    1832              : -    #set_validation(path: string, result: unknown) {
    1833            3 : +    #set_validation(state: DialogFieldState, result: unknown) {
    1834              :          if (result) {
    1835              :              const own = get_validation_result_own_string(result);
    1836              :              if (own) {
    1837              : -                this.#validation[path] = own;
    1838            3 : +                state.validation_text = own;
    1839              :                  this.#validation_failed = true;
    1840              :                  this.#online_validation = true;
    1841              : -                this.#update();
    1842              :              }
    1843              :              if (typeof result == "object") {
    1844              :                  for (const [k, v] of Object.entries(result)) {
    1845              : -                    if (k)
    1846              : -                        this.#set_validation(path ? path + "." + k : k, v);
    1847            2 : +                    const sub = k && state.sub.get(k);
    1848            2 : +                    if (sub)
    1849            2 : +                        this.#set_validation(sub, v);
    1850              :                  }
    1851              :              }
    1852              :          }
    1853              :      }
    1854              :  
    1855              : -    _get_validation(path: string): string | undefined {
    1856              : -        if (path in this.#validation)
    1857              : -            return this.#validation[path];
    1858              : -        else
    1859              : -            return undefined;
    1860              : -    }
    1861              : +    /* The field state has a cache of the most recently validated
    1862              : +       value.  If the current value is still the same, actual
    1863              : +       validation is skipped and the cached result from last time is
    1864              : +       used.
    1865              :  
    1866              : -    /* In between #trigger_validation and #set_validation, a lot is
    1867              : -       going on, especially with asynchronous validation.
    1868              : -
    1869              : -       We use a DialogValidationState object to keep the necessary
    1870              : -       state for that, such as cached results, and timeouts and
    1871              : -       promises.
    1872              : -
    1873              : -       Note that a DialogValidationState object can change which path
    1874              : -       it is for, see _rename_validation_state below. So we have to be
    1875              : -       careful to always get the path out of the DialogValidationState
    1876              : -       object.
    1877              : -     */
    1878              : -
    1879              : -    #get_validation_state(path: string): DialogValidationState {
    1880              : -        if (!(path in this.#validation_state))
    1881              : -            this.#validation_state[path] = {
    1882              : -                path,
    1883              : -                cached_value: undefined,
    1884              : -                cached_result: undefined,
    1885              : -                timeout_id: 0,
    1886              : -                promise: undefined,
    1887              : -                round_id: undefined,
    1888              : -            };
    1889              : -        return this.#validation_state[path];
    1890              : -    }
    1891              : -
    1892              : -    /* Calling #set_validation_state_result is the final thing that
    1893              : -       should happen when validating a given path. It will install the
    1894              : +       Calling #set_validation_state_result is the final thing that
    1895              : +       should happen when validating a field. It will install the
    1896              :         result in the cache and then call #set_validation.
    1897              :       */
    1898              :  
    1899              :      #set_validation_state_result(
    1900              : -        state: DialogValidationState,
    1901            3 : +        state: DialogFieldState,
    1902              :          val: unknown,
    1903              :          result: unknown,
    1904              :      ) {
    1905              :          state.cached_value = val;
    1906              :          state.cached_result = result;
    1907              : -        state.timeout_id = 0;
    1908              : -        state.promise = undefined;
    1909              : -        state.round_id = undefined;
    1910              : -        this.#set_validation(state.path, result);
    1911            3 : +        this.#set_validation(state, result);
    1912              :      }
    1913              :  
    1914              :      /* The first thing should be of course to probe that cache.  If we
    1915              :         get a hit, it is used immediately to call #set_validation.
    1916              : -
    1917              : -       In that case, the DialogValidationState is also made part of
    1918              : -       the current round since any asynchronous validation that is
    1919              : -       currently running is still relevant. See below for more about
    1920              : -       that.
    1921              :       */
    1922              :  
    1923              : -    #probe_validation_state_cache(state: DialogValidationState, val: unknown): boolean {
    1924            3 : +    #probe_validation_state_cache(state: DialogFieldState, val: unknown): boolean {
    1925              :          if (Object.is(state.cached_value, val)) {
    1926              : -            state.round_id = this.#get_current_validation_round_id();
    1927              : -            debug("cache hit", state.path, state.cached_result);
    1928              : -            this.#set_validation(state.path, state.cached_result);
    1929            3 : +            debug("cache hit", state_path(state), JSON.stringify(val), state.cached_result);
    1930            3 : +            this.#set_validation(state, state.cached_result);
    1931              :              return true;
    1932              :          } else
    1933              :              return false;
    1934              : @@ -967,254 +1191,142 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
    1935              :      /* And in fact, _validate_value does exactly those two things.
    1936              :       */
    1937              :  
    1938              : -    _validate_value(path: string, val: unknown, func: () => unknown): void {
    1939              : -        const state = this.#get_validation_state(path);
    1940            3 : +    _validate_value(state: DialogFieldState, val: unknown, func: () => unknown): void {
    1941            3 : +        state.relevant = true;
    1942              :          if (!this.#probe_validation_state_cache(state, val)) {
    1943              :              const result = func();
    1944              : -            debug("sync validate", state.path, result);
    1945            3 : +            debug("sync validate", state_path(state), JSON.stringify(result));
    1946              :              this.#set_validation_state_result(state, val, result);
    1947              :          }
    1948              :      }
    1949              :  
    1950              :      /* Now asynchronous validation.
    1951              :  
    1952              : -       Each call to #trigger_validation starts a new "validation
    1953              : -       round" and a DialogValidationState keeps track to which round
    1954              : -       it applies to.  This matters of course for asynchronous
    1955              : -       validation: If async validation for a given path was started in
    1956              : -       one round, and then the next round happens but the path is no
    1957              : -       longer enumerated by the validation callback (i.e., its value
    1958              : -       is no longer relevant for the dialog), then this asynchronous
    1959              : -       validation should have no effect.
    1960              : -
    1961              : -       We use the #validation object as the round identifier, since it
    1962              : -       is created fresh by each call to #trigger_validation.
    1963              : -     */
    1964              : -
    1965              : -    #get_current_validation_round_id(): unknown {
    1966              : -        return this.#validation;
    1967              : -    }
    1968              : -
    1969              : -    #is_current_validation_round_id(id: unknown): boolean {
    1970              : -        return Object.is(id, this.#validation);
    1971              : -    }
    1972              : -
    1973              : -    /* If there was no cache hit, asynchronous validation starts with
    1974              : +       If there was no cache hit, asynchronous validation starts with
    1975              :         a timeout, followed by letting a asynchronous function run to
    1976              : -       resolution.
    1977              : +       resolution.  This is managed by a DialogTask.
    1978              :  
    1979              : -       Setting a new timeout of course cancels any previously set
    1980              : -       one. It also installs the current value in the cache, so that
    1981              : -       subsequent validation rounds do nothing until the value
    1982              : -       actually changes.
    1983              : +       Starting a new task of course aborts any previous one. It also
    1984              : +       installs the current value in the cache, so that subsequent
    1985              : +       validation rounds do nothing until the value actually changes.
    1986              :  
    1987              : -       One interesting thing to note is that when doing the final
    1988              : -       validation before running an action function, no debouncing
    1989              : -       delay should be applied of course. We want to get on with
    1990              : -       validation immediately.
    1991              : +       When the validation result has been computed, we need to check
    1992              : +       whether we have been aborted so that we don't install
    1993              : +       out-dated results.
    1994              :       */
    1995              :  
    1996              : -    #set_validation_state_timeout(
    1997              : -        state: DialogValidationState,
    1998            1 : +    _validate_value_async(
    1999            1 : +        state: DialogFieldState,
    2000              :          val: unknown,
    2001              : -        delay: number,
    2002              : -        func: () => void,
    2003              : -    ) {
    2004              : -        if (state.timeout_id) {
    2005              : -            debug("timeout cancel", state.path);
    2006              : -            window.clearTimeout(state.timeout_id);
    2007              : -            state.timeout_id = 0;
    2008              : -        }
    2009              : -        if (this.#action_running || delay == 0) {
    2010              : -            func();
    2011              : -        } else {
    2012            1 : +        debounce: number,
    2013            1 : +        func: (signal: AbortSignal) => Promise<unknown>
    2014            1 : +    ): void {
    2015            1 : +        state.relevant = true;
    2016            1 : +        if (!this.#probe_validation_state_cache(state, val)) {
    2017              :              state.cached_value = val;
    2018              :              state.cached_result = undefined;
    2019              : -            state.timeout_id = window.setTimeout(
    2020              : -                () => {
    2021              : -                    debug("timeout", state.path);
    2022              : -                    if (!this.#validation_state_is_current(state)) {
    2023              : -                        debug("timeout outdated", state.path);
    2024              : -                        return;
    2025              : -                    }
    2026              : -                    func();
    2027              : -                },
    2028              : -                delay);
    2029              : -            state.promise = undefined;
    2030              : -            state.round_id = this.#get_current_validation_round_id();
    2031              : -        }
    2032              : -    }
    2033              :  
    2034              : -    /* Once the timeout is over (and the path is still relevant to the
    2035              : -       current round), the actual asynchronous validation is launched.
    2036              : -       This promise that represents it is simply installed in the
    2037              : -       DialogValidationState.
    2038              : -     */
    2039              : -
    2040              : -    #set_validation_state_promise(
    2041              : -        state: DialogValidationState,
    2042              : -        val: unknown,
    2043              : -        prom: Promise<void>,
    2044              : -    ) {
    2045              : -        state.cached_value = val;
    2046              : -        state.cached_result = undefined;
    2047              : -        state.timeout_id = 0;
    2048              : -        state.promise = prom;
    2049              : -        state.round_id = this.#get_current_validation_round_id();
    2050              : -    }
    2051              : -
    2052              : -    /* Unlike with the timeout, we can not cancel the old promise when
    2053              : -       installing a new one. Instead we check at the end whether it is
    2054              : -       still really us that is supposed to deliver the result, by
    2055              : -       comparing promises.
    2056              : -
    2057              : -       To summarize:
    2058              : -
    2059              : -       - The round id check will fail if the value is no longer
    2060              : -         relevant to the dialog.  For example, say there is a text
    2061              : -         input that can be toggled in and out of the dialog via a
    2062              : -         checkbox. Now a validation round is started while the text
    2063              : -         input is part of the dialog. During the debounce timeout or
    2064              : -         while the asynchronous validation function runs, the user
    2065              : -         toggles the checkbox (which triggers a new validation round)
    2066              : -         and the text input is no longer part of the dialog. Now when
    2067              : -         the timeout or validation for the text input concludes, the
    2068              : -         round id check fails and the result is ignored, as it should.
    2069              : -
    2070              : -       - The promise check will fail when a asynchronous validation
    2071              : -         takes longer than the debounce timeout.  Let's say there is a
    2072              : -         text input with a debounce timeout of 1 second and a
    2073              : -         validation function that takes 2 seconds. The user makes a
    2074              : -         change that triggers validation and then remains idle for
    2075              : -         more than a second. After one second, the timeout expires and
    2076              : -         the promise is created and starts running. It will finish at
    2077              : -         second 3, but we are not there yet. At second 1.5 the user
    2078              : -         makes another change, a new timeout expires at 2.5 and a new
    2079              : -         promise is created. At second 3 the original promise finally
    2080              : -         comes to a conclusion, and the path is still relevant to the
    2081              : -         dialog, but this promise is no longer the current
    2082              : -         promise. Its result will be ignored, as it should.
    2083              : -     */
    2084              : -
    2085              : -    #validation_state_is_current(state: DialogValidationState, prom?: Promise<void>): boolean {
    2086              : -        return (
    2087              : -            (!prom || Object.is(state.promise, prom)) &&
    2088              : -                this.#is_current_validation_round_id(state.round_id)
    2089              : -        );
    2090              : -    }
    2091              : -
    2092              : -    /* _validate_value_async puts this all together.
    2093              : -     */
    2094              : -
    2095              : -    _validate_value_async(path: string, val: unknown, debounce: number, func: () => Promise<unknown>): void {
    2096              : -        const state = this.#get_validation_state(path);
    2097              : -        if (!this.#probe_validation_state_cache(state, val)) {
    2098              : -            debug("async validate start debounce", state.path, val);
    2099              : -            this.#set_validation_state_timeout(
    2100              : -                state,
    2101              : -                val,
    2102            1 : +            if (state.validation_task)
    2103            1 : +                state.validation_task.abort();
    2104            1 : +            state.validation_task = new DialogTask(
    2105            1 : +                state_path(state) + ":validate",
    2106              :                  debounce,
    2107              : -                () => {
    2108              : -                    debug("async validate start promise", state.path, val);
    2109              : -                    const prom =
    2110              : -                        func()
    2111              : -                                .catch(
    2112              : -                                    ex => {
    2113              : -                                        console.error(ex);
    2114              : -                                        return undefined;
    2115              : -                                    }
    2116              : -                                )
    2117              : -                                .then(
    2118              : -                                    result => {
    2119              : -                                        if (this.#validation_state_is_current(state, prom)) {
    2120              : -                                            debug("async validate done", state.path, result);
    2121              : -                                            this.#set_validation_state_result(state, val, result);
    2122              : -                                        } else {
    2123              : -                                            debug("promise outdated", state.path);
    2124              : -                                        }
    2125              : -                                    }
    2126              : -                                );
    2127              : -                    this.#set_validation_state_promise(state, val, prom);
    2128            1 : +                async task => {
    2129            1 : +                    const signal = task.get_abort_signal();
    2130            1 : +                    let result;
    2131            1 : +                    try {
    2132            1 : +                        result = await func(signal);
    2133            1 : +                    } catch (ex) {
    2134            1 : +                        console.error(ex);
    2135            1 : +                    }
    2136            1 : +                    if (!signal.aborted) {
    2137            1 : +                        debug("async validate result", state_path(state), result);
    2138            1 : +                        this.#set_validation_state_result(state, val, result);
    2139            1 : +                        this.#update();
    2140            1 : +                    }
    2141            1 : +                },
    2142            1 : +                task => {
    2143            1 : +                    if (state.validation_task == task)
    2144            1 : +                        state.validation_task = null;
    2145              :                  }
    2146              :              );
    2147              :          }
    2148              :      }
    2149              :  
    2150              : -    /* Since the DialogValidationState for a path is so important, it
    2151              : -       is also important to keep them firmly associated with each
    2152              : -       other when the path of a value changes.
    2153            2 : +    _set_value_async(
    2154            2 : +        state: DialogFieldState,
    2155            2 : +        debounce: number,
    2156            2 : +        func: (signal: AbortSignal) => Promise<void>
    2157            2 : +    ): void {
    2158            2 : +        const task = new DialogTask(
    2159            2 : +            state_path(state) + ":set",
    2160            2 : +            debounce,
    2161            2 : +            async task => {
    2162            2 : +                try {
    2163            2 : +                    await func(task.get_abort_signal());
    2164            0 : +                } catch (ex) {
    2165            0 : +                    console.error(ex);
    2166            0 : +                }
    2167            2 : +            },
    2168            2 : +            task => {
    2169            2 : +                if (state.update_task == task)
    2170            2 : +                    state.update_task = null;
    2171            2 : +            }
    2172            2 : +        );
    2173              :  
    2174              : -       A path might change when there are arrays involved, and
    2175              : -       elements get new indices without actually changing identity.
    2176              : -     */
    2177            2 : +        if (state.update_task)
    2178            2 : +            state.update_task.abort();
    2179            2 : +        state.update_task = task;
    2180            2 : +    }
    2181              :  
    2182              : -    _rename_validation_state(path: string, from: number, to: number) {
    2183              : -        const from_path = path + "." + String(from);
    2184              : -        const to_path = path + "." + String(to);
    2185              : -        if (from_path in this.#validation_state) {
    2186              : -            debug("rename", from_path, to_path);
    2187              : -            this.#validation_state[to_path] = this.#validation_state[from_path];
    2188              : -            this.#validation_state[to_path].path = to_path;
    2189              : -            delete this.#validation_state[from_path];
    2190              : -        }
    2191              : -        for (const k in this.#validation_state) {
    2192              : -            if (k.indexOf(from_path + ".") == 0) {
    2193              : -                const to = to_path + k.substring(from_path.length);
    2194              : -                debug("rename", k, to);
    2195              : -                this.#validation_state[to] = this.#validation_state[k];
    2196              : -                this.#validation_state[to].path = to;
    2197              : -                delete this.#validation_state[k];
    2198            1 : +    _get_value_async(
    2199            1 : +        state: DialogFieldState,
    2200            1 : +        debounce: number,
    2201            1 : +        func: (signal: AbortSignal) => Promise<void>
    2202            1 : +    ): void {
    2203            1 : +        const task = new DialogTask(
    2204            1 : +            state_path(state) + ":get",
    2205            1 : +            debounce,
    2206            1 : +            async task => {
    2207            1 : +                try {
    2208            1 : +                    await func(task.get_abort_signal());
    2209            0 : +                } catch (ex) {
    2210            0 : +                    console.error(ex);
    2211            0 : +                }
    2212            1 : +            },
    2213            1 : +            task => {
    2214            1 : +                state.update_tasks.delete(task);
    2215              :              }
    2216              : -        }
    2217            1 : +        );
    2218              : +
    2219            1 : +        state.update_tasks.add(task);
    2220              :      }
    2221              :  
    2222              :      /* The first thing run_action does is to trigger a new validation
    2223              :         round and then wait for all the asynchronous results to have
    2224              :         come in.
    2225              :  
    2226              : -       If there are any DialogValidationState objects that are waiting
    2227              : -       for a timeout, we want to cancel those and start over, so that
    2228              : +       If there are any DialogFieldState objects that are waiting
    2229              : +       for a timeout, we want to abort those and start over, so that
    2230              :         their validation starts immediately. (Also, it would be hairy
    2231              :         to wait for those timeouts to be over from here.)
    2232              :       */
    2233              :  
    2234              :      async validate(): Promise<boolean> {
    2235              : -        this.#cancel_all_validation_timeouts();
    2236            4 : +        this.#online_validation = true;
    2237              :          this.#trigger_validation();
    2238              : -        await this.#wait_for_validation_promises();
    2239            4 : +        await this._run_all_tasks_now();
    2240              :          return !this.#validation_failed;
    2241              :      }
    2242              :  
    2243              : -    #cancel_all_validation_timeouts() {
    2244              : -        for (const p in this.#validation_state) {
    2245              : -            const state = this.#validation_state[p];
    2246              : -            if (state.timeout_id) {
    2247              : -                debug("timeout bulk cancel", p);
    2248              : -                window.clearTimeout(state.timeout_id);
    2249              : -                delete this.#validation_state[p];
    2250              : -            }
    2251              : -        }
    2252              : -    }
    2253              : -
    2254              : -    async #wait_for_validation_promises(): Promise<void> {
    2255              : -        for (const path in this.#validation_state) {
    2256              : -            const state = this.#validation_state[path];
    2257              : -            if (state.promise) {
    2258              : -                debug("waiting for promise", path);
    2259              : -                await state.promise;
    2260              : -                debug("waiting for promise done", path);
    2261              : -            }
    2262              : -        }
    2263              : -    }
    2264              : -
    2265              :      set_cancel(cancel: (() => void) | null) {
    2266              : -        this.cancel_function = cancel;
    2267            3 : +        this.#cancel_function = cancel;
    2268              :          this.#update();
    2269              :      }
    2270              :  
    2271              :      async run_action(func: (vals: V) => Promise<void>): Promise<boolean> {
    2272              :          this.error = null;
    2273              : -        this.cancel_function = null;
    2274            4 : +        this.#cancel_function = null;
    2275              :          this.#action_running = true;
    2276              :          this.#update();
    2277              :          if (!await this.validate()) {
    2278              : @@ -1224,26 +1336,39 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
    2279              :          }
    2280              :  
    2281              :          try {
    2282            4 : +            this.#block_updates = true;
    2283              :              await func(this.values);
    2284              :          } catch (ex) {
    2285              :              console.error(String(ex));
    2286              :              this.error = ex;
    2287              :          }
    2288              :  
    2289              : -        this.cancel_function = null;
    2290            4 : +        this.#cancel_function = null;
    2291              :          this.#action_running = false;
    2292            4 : +        this.#block_updates = false;
    2293              :          this.#update();
    2294              :  
    2295              :          return !this.error;
    2296              :      }
    2297              :  
    2298            3 : +    cancel(onClose: () => void): void {
    2299            2 : +        if (this.#action_running) {
    2300            2 : +            if (this.#cancel_function)
    2301            2 : +                this.#cancel_function();
    2302            2 : +        } else {
    2303            3 : +            this._abort_state_tasks(this.#top_state);
    2304            3 : +            onClose();
    2305            3 : +        }
    2306            3 : +    }
    2307              : +
    2308              :      top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
    2309              :          return new DialogField<V>(
    2310              :              this as DialogState<unknown>,
    2311            4 : +            this.#top_state,
    2312              :              () => this.values,
    2313              :              (val) => {
    2314              :                  debug("set", val);
    2315              : -                if (this.#action_running) {
    2316            2 : +                if (this.#block_updates) {
    2317              :                      // Deny state changes while actions run.  This
    2318              :                      // prevents the user from interacting with the
    2319              :                      // dialog while it is busy. The alternative would
    2320              : @@ -1261,7 +1386,7 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
    2321              :                  if (update_func)
    2322              :                      update_func(val);
    2323              :              },
    2324              : -            "");
    2325            4 : +        );
    2326              :      }
    2327              :  
    2328              :      field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
    2329              : @@ -1353,7 +1478,7 @@ export function DialogErrorMessage<V>({
    2330              :  
    2331              :      return (
    2332              :          <Alert
    2333              : -            id="dialog-error-message"
    2334            3 : +            ouiaId="dialog-error-message"
    2335              :              variant='danger'
    2336              :              isInline
    2337              :              title={title}
    2338              : @@ -1377,7 +1502,7 @@ export function DialogActionButton<V>({
    2339              :  } & Omit<ButtonProps, "id" | "action" | "isLoading" | "isDisabled" | "variant" | "onClick">) {
    2340              :      return (
    2341              :          <Button
    2342              : -            id="dialog-apply"
    2343            4 : +            ouiaId="dialog-apply"
    2344              :              isLoading={!!dialog && !(dialog instanceof DialogError) && dialog.busy}
    2345              :              isDisabled={!dialog || dialog instanceof DialogError || dialog.actions_disabled}
    2346              :              variant="primary"
    2347              : @@ -1403,12 +1528,12 @@ export function DialogCancelButton<V>({
    2348              :  } & Omit<ButtonProps, "id" | "isDisabled" | "variant" | "onClick">) {
    2349              :      return (
    2350              :          <Button
    2351              : -            id="dialog-cancel"
    2352            4 : +            ouiaId="dialog-cancel"
    2353              :              isDisabled={!dialog || (dialog instanceof DialogState && dialog.cancel_disabled)}
    2354              :              variant="link"
    2355              :              onClick={() => {
    2356              : -                if (dialog instanceof DialogState && dialog.cancel_function)
    2357              : -                    dialog.cancel_function();
    2358            2 : +                if (dialog instanceof DialogState)
    2359            1 : +                    dialog.cancel(onClose);
    2360              :                  else
    2361              :                      onClose();
    2362              :              }}
    2363              : @@ -1456,7 +1581,7 @@ export function DialogHelperText<V>({
    2364              :      return (
    2365              :          <FormHelperText>
    2366              :              <HelperText>
    2367              : -                <HelperTextItem id={field.id("helper-text")} variant={variant}>
    2368            4 : +                <HelperTextItem data-ouia-component-id={field.ouia_id("helper-text")} variant={variant}>
    2369              :                      {text}
    2370              :                  </HelperTextItem>
    2371              :              </HelperText>
    2372              : @@ -1503,9 +1628,10 @@ export const DialogTextInput = ({
    2373              :      isDisabled?: boolean,
    2374              :  } & Omit<TextInputProps, "id" | "label" | "value" | "onChange">) => {
    2375              :      return (
    2376              : -        <OptionalFormGroup label={label} fieldId={field.id()}>
    2377            3 : +        <OptionalFormGroup label={label} fieldId={field.random_id()}>
    2378              :              <TextInput
    2379              : -                id={field.id()}
    2380            3 : +                id={field.random_id()}
    2381            3 : +                ouiaId={field.ouia_id()}
    2382              :                  value={field.get()}
    2383              :                  onChange={(_event, val) => field.set(val)}
    2384              :                  isDisabled={!!excuse || isDisabled}
    2385              : @@ -1535,11 +1661,12 @@ export const DialogPasswordInput = ({
    2386              :      const [visible, setVisible] = useState(false);
    2387              :  
    2388              :      return (
    2389              : -        <OptionalFormGroup label={label} fieldId={field.id()}>
    2390            2 : +        <OptionalFormGroup label={label} fieldId={field.random_id()}>
    2391              :              <InputGroup>
    2392              :                  <InputGroupItem isFill>
    2393              :                      <TextInput
    2394              : -                        id={field.id()}
    2395            2 : +                        id={field.random_id()}
    2396            2 : +                        ouiaId={field.ouia_id()}
    2397              :                          type={visible ? "text" : "password"}
    2398              :                          value={field.get()}
    2399              :                          onChange={(_event, value) => field.set(value)}
    2400              : @@ -1580,7 +1707,8 @@ export const DialogCheckbox = ({
    2401              :      return (
    2402              :          <OptionalFormGroup label={field_label} hasNoPaddingTop>
    2403              :              <Checkbox
    2404              : -                id={field.id()}
    2405            2 : +                id={field.random_id()}
    2406            2 : +                ouiaId={field.ouia_id()}
    2407              :                  isChecked={field.get()}
    2408              :                  label={checkbox_label}
    2409              :                  onChange={(_event, checked) => field.set(checked)}
    2410              : @@ -1617,7 +1745,7 @@ export function DialogRadioSelect<T extends string>({
    2411              :          const exc = o.excuse ? <> ({o.excuse})</> : null;
    2412              :          const pad = (!isInline && i < options.length - 1) ? <><br />{"\u00A0"}</> : null;
    2413              :          const exp = o.explanation ? <><br /><small>{o.explanation}{pad}</small></> : null;
    2414              : -        return <div id={field.id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
    2415            2 : +        return <div data-ouia-component-id={field.ouia_id(o.value + "-label")}>{o.label}{exc}{exp}</div>;
    2416              :      }
    2417              :  
    2418              :      return (
    2419              : @@ -1625,14 +1753,15 @@ export function DialogRadioSelect<T extends string>({
    2420              :              label={label}
    2421              :              hasNoPaddingTop
    2422              :              isInline={isInline}
    2423              : -            id={field.id()}
    2424            2 : +            data-ouia-component-id={field.ouia_id()}
    2425              :              data-value={field.get()}
    2426              :          >
    2427              :              {
    2428              :                  options.map((o, i) =>
    2429              :                      <Radio
    2430              :                          key={o.value}
    2431              : -                        id={field.id(o.value)}
    2432            2 : +                        id={field.random_id() + o.value}
    2433            2 : +                        ouiaId={field.ouia_id(o.value)}
    2434              :                          name={o.value}
    2435              :                          isChecked={field.get() == o.value}
    2436              :                          label={makeLabel(o, i)}
    2437              : @@ -1670,7 +1799,8 @@ export function DialogDropdownSelect<T extends string>({
    2438              :      return (
    2439              :          <OptionalFormGroup label={label}>
    2440              :              <FormSelect
    2441              : -                id={field.id()}
    2442            3 : +                id={field.random_id()}
    2443            3 : +                ouiaId={field.ouia_id()}
    2444              :                  onChange={(_event, val) => field.set(val as T) }
    2445              :                  validated={warning ? "warning" : undefined}
    2446              :                  isDisabled={!!excuse}
    2447              : @@ -1711,7 +1841,8 @@ export function DialogDropdownSelectObject<T>({
    2448              :      return (
    2449              :          <OptionalFormGroup label={label}>
    2450              :              <FormSelect
    2451              : -                id={field.id()}
    2452            2 : +                id={field.random_id()}
    2453            2 : +                ouiaId={field.ouia_id()}
    2454              :                  onChange={(_event, val) => {
    2455              :                      const opt = options.find(o => option_label(o) == val);
    2456              :                      field.set(opt!);
    2457              : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
    2458              : index d5ca451df..49295d143 100644
    2459              : --- a/pkg/playground/dialog.tsx
    2460              : +++ b/pkg/playground/dialog.tsx
    2461              : @@ -36,6 +36,8 @@ import {
    2462              :      DialogActionButton, DialogCancelButton,
    2463              :  } from 'cockpit/dialog';
    2464              :  
    2465              : +import { DialogFileChooserInput } from "cockpit/FileChooser";
    2466              : +
    2467              :  import 'cockpit-dark-theme'; // once per page
    2468              :  import 'page.scss';
    2469              :  
    2470              : @@ -51,7 +53,7 @@ function List<T>({
    2471              :      init: T,
    2472              :  }) {
    2473              :      return (
    2474              : -        <FormGroup label={label} id={field.id()}>
    2475            2 : +        <FormGroup label={label} data-ouia-component-id={field.ouia_id()}>
    2476              :              { field.map((f, i) => (
    2477              :                  <Split key={i}>
    2478              :                      <SplitItem isFilled>
    2479              : @@ -59,7 +61,7 @@ function List<T>({
    2480              :                      </SplitItem>
    2481              :                      <SplitItem>
    2482              :                          <Button
    2483              : -                            id={f.id("remove")}
    2484            1 : +                            ouiaId={f.ouia_id("remove")}
    2485              :                              variant="link"
    2486              :                              onClick={() => field.remove(i)}
    2487              :                          >
    2488              : @@ -70,7 +72,7 @@ function List<T>({
    2489              :              ))}
    2490              :              <DialogHelperText field={field} />
    2491              :              <Button
    2492              : -                id={field.id("add")}
    2493            2 : +                ouiaId={field.ouia_id("add")}
    2494              :                  variant="link"
    2495              :                  onClick={() => field.add(init)}
    2496              :              >
    2497              : @@ -99,7 +101,7 @@ const StringList = ({
    2498              :  
    2499              :  interface Name {
    2500              :      name: string;
    2501              : -    _length_cache: Record<string, number>;
    2502              : +    _length: number;
    2503              :  }
    2504              :  
    2505              :  const NameInput = ({
    2506              : @@ -111,11 +113,11 @@ const NameInput = ({
    2507              :  };
    2508              :  
    2509              :  function validate_Name(field: DialogField<Name>, countAsyncValidation: () => void) {
    2510              : -    const { _length_cache } = field.get();
    2511              : -    field.sub("name").validate_async(1000, async n => {
    2512            1 : +    field.sub("name").validate_async(1000, async (n, signal) => {
    2513              :          await async_sleep(2000);
    2514              :          countAsyncValidation();
    2515              : -        _length_cache[n] = n.length;
    2516            1 : +        if (!signal.aborted)
    2517            1 : +            field.sub("_length").set(n.length);
    2518              :          if (n.length % 2)
    2519              :              return "Must have even number of characters";
    2520              :      });
    2521              : @@ -133,7 +135,7 @@ const NameList = ({
    2522              :              label={label}
    2523              :              field={field}
    2524              :              Component={NameInput}
    2525              : -            init={{ name: "", _length_cache: { } }}
    2526            2 : +            init={{ name: "", _length: 0 }}
    2527              :          />
    2528              :      );
    2529              :  };
    2530              : @@ -153,7 +155,8 @@ const OptionalTextInput = ({
    2531              :      if (val === false) {
    2532              :          body = (
    2533              :              <Checkbox
    2534              : -                id={field.id("checkbox")}
    2535            2 : +                id={field.random_id()}
    2536            2 : +                ouiaId={field.ouia_id("checkbox")}
    2537              :                  isChecked={false}
    2538              :                  label={checkbox_label}
    2539              :                  onChange={() => field.set("")}
    2540              : @@ -163,7 +166,8 @@ const OptionalTextInput = ({
    2541              :          body = (
    2542              :              <>
    2543              :                  <Checkbox
    2544              : -                    id={field.id("checkbox")}
    2545            1 : +                    id={field.random_id()}
    2546            1 : +                    ouiaId={field.ouia_id("checkbox")}
    2547              :                      isChecked
    2548              :                      label={checkbox_label}
    2549              :                      onChange={() => field.set(false)}
    2550              : @@ -205,34 +209,48 @@ const colors: Color[] = [
    2551              :  interface ExampleValues {
    2552              :      flag: boolean;
    2553              :      text: string;
    2554              : +    text2: string;
    2555              :      radio: string;
    2556              :      dropdown: string;
    2557              : +    text3: string;
    2558              :      color: Color,
    2559              :      list: string[];
    2560              :      async: Name[];
    2561              :      alternative: false | string;
    2562              :      error: string;
    2563              : +    file: string;
    2564              : +    file_explanation: string;
    2565              : +    dir: string;
    2566              :  }
    2567              :  
    2568              :  const ExampleDialog = ({
    2569              :      setResult,
    2570              :      countAsyncValidation,
    2571            2 : +    countAsyncUpdate,
    2572            2 : +    countAsyncCancel,
    2573              :  } : {
    2574              :      setResult: (values: ExampleValues) => void,
    2575              :      countAsyncValidation: () => void,
    2576              : +    countAsyncUpdate: () => void,
    2577              : +    countAsyncCancel: () => void,
    2578              :  }) => {
    2579              :      const Dialogs = useDialogs();
    2580              :  
    2581              :      const init: ExampleValues = {
    2582              :          flag: false,
    2583              :          text: "",
    2584            2 : +        text2: "",
    2585              :          radio: "one",
    2586              :          dropdown: "one",
    2587            2 : +        text3: "",
    2588              :          color: colors[0],
    2589              :          list: [],
    2590              :          async: [],
    2591              :          alternative: false,
    2592              :          error: "none",
    2593            2 : +        file: "",
    2594            2 : +        file_explanation: "",
    2595            2 : +        dir: "",
    2596              :      };
    2597              :  
    2598              :      function validate(dlg: DialogState<ExampleValues>) {
    2599              : @@ -242,13 +260,25 @@ const ExampleDialog = ({
    2600              :                      return "Text can not be empty";
    2601              :              });
    2602              :          }
    2603            1 : +        if (dlg.values.dropdown == "three") {
    2604            1 : +            dlg.field("text3").validate_async(1000, async v => {
    2605            1 : +                if (!v)
    2606            1 : +                    return "Can't be empty";
    2607            1 : +            });
    2608            1 : +        }
    2609              :          dlg.field("list").forEach(v => {
    2610              :              v.validate(vv => {
    2611            1 : +                if (vv == "magic")
    2612            1 : +                    dlg.field("text").set("magic");
    2613              :                  if (vv == ".")
    2614              :                      return "No dots";
    2615              :              });
    2616              :          });
    2617              :          dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
    2618            1 : +        dlg.field("file").validate(v => {
    2619            0 : +            if (v && v[0] != "/")
    2620            0 : +                return "Must be absolute";
    2621            1 : +        });
    2622              :      }
    2623              :  
    2624              :      const dlg = useDialogState(init, validate);
    2625              : @@ -275,8 +305,31 @@ const ExampleDialog = ({
    2626              :          }
    2627              :      }
    2628              :  
    2629              : -    function update_color(color: Color) {
    2630              : -        dlg.field("text").set(color.name);
    2631            1 : +    function update_color() {
    2632            1 : +        dlg.field("color").get_async(0, async (val, signal) => {
    2633            1 : +            signal.onabort = countAsyncCancel;
    2634            1 : +            await async_sleep(2000);
    2635            1 : +            if (!signal.aborted) {
    2636            1 : +                countAsyncUpdate();
    2637            1 : +                dlg.field("text").set(val.name);
    2638            1 : +            }
    2639            1 : +        });
    2640            1 : +    }
    2641              : +
    2642            1 : +    function update_dropdown(val: string) {
    2643            1 : +        dlg.field("text2").set_async(0, async () => {
    2644            1 : +            await async_sleep(2000);
    2645            1 : +            return val;
    2646            1 : +        });
    2647            1 : +    }
    2648              : +
    2649            1 : +    function update_file(val: string) {
    2650            1 : +        dlg.field("file_explanation").set_async(250, async () => {
    2651            1 : +            if (val[0] == "/")
    2652            1 : +                return cockpit.spawn(["file", "-b", val], { superuser: "try" });
    2653              : +            else
    2654            1 : +                return "--";
    2655            1 : +        });
    2656              :      }
    2657              :  
    2658              :      return (
    2659              : @@ -303,6 +356,10 @@ const ExampleDialog = ({
    2660              :                          explanation="Explanation"
    2661              :                          warning={dlg.values.text == "warn" ? "Warning" : null}
    2662              :                      />
    2663            2 : +                    <DialogTextInput
    2664            2 : +                        label="Text2"
    2665            2 : +                        field={dlg.field("text2")}
    2666            2 : +                    />
    2667              :                      {
    2668              :                          // Calling "map" on a non-array should just do nothing.
    2669              :                          dlg.field("text").map((v, i) => <span key={i}>{v.get()}</span>)
    2670              : @@ -332,7 +389,7 @@ const ExampleDialog = ({
    2671              :                      />
    2672              :                      <DialogDropdownSelect
    2673              :                          label="Dropdown"
    2674              : -                        field={dlg.field("dropdown")}
    2675            2 : +                        field={dlg.field("dropdown", update_dropdown)}
    2676              :                          options={
    2677              :                              [
    2678              :                                  { value: "one", label: "Eins" },
    2679              : @@ -342,6 +399,10 @@ const ExampleDialog = ({
    2680              :                          }
    2681              :                          warning={dlg.field("dropdown").get() == "two" ? "There is a discount if you buy three." : null}
    2682              :                      />
    2683              : +                    {
    2684            2 : +                        dlg.values.dropdown == "three" &&
    2685            1 : +                            <DialogTextInput label="Text3" field={dlg.field("text3")} />
    2686              : +                    }
    2687              :                      <DialogDropdownSelectObject
    2688              :                          label="DropdownObject"
    2689              :                          field={dlg.field("color", update_color)}
    2690              : @@ -361,6 +422,48 @@ const ExampleDialog = ({
    2691              :                          options={["none", "custom", "from", "from-random", "message", "spawn", "random"]}
    2692              :                          warning={dlg.field("error").get() != "none" ? "There will be an error" : null}
    2693              :                      />
    2694            2 : +                    <DialogFileChooserInput
    2695            2 : +                        label="File"
    2696            2 : +                        field={dlg.field("file", update_file)}
    2697            2 : +                        explanation={dlg.values.file_explanation}
    2698            2 : +                        fileChooserProps={
    2699            2 : +                            {
    2700            2 : +                                title: "Select a file",
    2701            2 : +                                superuser: "try",
    2702            2 : +                                filters: [
    2703            1 : +                                    { label: "No dots", filter: n => !n.includes(".") },
    2704            2 : +                                ],
    2705            2 : +                                shortcuts: [
    2706            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    2707            2 : +                                ],
    2708            2 : +                                collections: [
    2709            2 : +                                    {
    2710            2 : +                                        label: "Some files",
    2711            1 : +                                        list: async () => {
    2712            1 : +                                            return [
    2713            1 : +                                                { name: "/home/admin/.ssh/id_rsa", type: "reg" },
    2714            1 : +                                                { name: "/home/admin/Pictures", type: "dir" },
    2715            1 : +                                            ];
    2716            1 : +                                        }
    2717            2 : +                                    }
    2718            2 : +                                ]
    2719            2 : +                            }
    2720              : +                        }
    2721            2 : +                    />
    2722            2 : +                    <DialogFileChooserInput
    2723            2 : +                        label="Directory"
    2724            2 : +                        field={dlg.field("dir")}
    2725            2 : +                        fileChooserProps={
    2726            2 : +                            {
    2727            2 : +                                title: "Select a directory",
    2728            2 : +                                onlyDirectories: true,
    2729            2 : +                                superuser: "try",
    2730            2 : +                                shortcuts: [
    2731            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    2732            2 : +                                ],
    2733            2 : +                            }
    2734              : +                        }
    2735            2 : +                    />
    2736              :                  </Form>
    2737              :              </ModalBody>
    2738              :              <ModalFooter>
    2739              : @@ -376,8 +479,12 @@ const ExampleDialog = ({
    2740              :  const ExampleButton = () => {
    2741              :      const Dialogs = useDialogs();
    2742              :      const [values, setValues] = useState<ExampleValues | null>(null);
    2743              : -    const [asyncValidationsBase, setAsycountAsyncValidationsBase] = useState<number>(0);
    2744            2 : +    const [asyncValidationsBase, setAsyncValidationsBase] = useState<number>(0);
    2745              :      const [asyncValidations, countAsyncValidation] = useReducer(x => x + 1, 0);
    2746            2 : +    const [asyncUpdatesBase, setAsyncUpdatesBase] = useState<number>(0);
    2747            1 : +    const [asyncUpdates, countAsyncUpdate] = useReducer(x => x + 1, 0);
    2748            2 : +    const [asyncCancelsBase, setAsyncCancelsBase] = useState<number>(0);
    2749            1 : +    const [asyncCancels, countAsyncCancel] = useReducer(x => x + 1, 0);
    2750              :  
    2751              :      function entry(id: string, val: string) {
    2752              :          return (
    2753              : @@ -394,11 +501,15 @@ const ExampleButton = () => {
    2754              :                  id="open"
    2755              :                  onClick={
    2756              :                      () => {
    2757              : -                        setAsycountAsyncValidationsBase(asyncValidations);
    2758            2 : +                        setAsyncValidationsBase(asyncValidations);
    2759            2 : +                        setAsyncUpdatesBase(asyncUpdates);
    2760            2 : +                        setAsyncCancelsBase(asyncCancels);
    2761              :                          Dialogs.show(
    2762              :                              <ExampleDialog
    2763              :                                  setResult={setValues}
    2764              :                                  countAsyncValidation={countAsyncValidation}
    2765            2 : +                                countAsyncUpdate={countAsyncUpdate}
    2766            2 : +                                countAsyncCancel={countAsyncCancel}
    2767              :                              />
    2768              :                          );
    2769              :                      }
    2770              : @@ -410,12 +521,15 @@ const ExampleButton = () => {
    2771              :                  <DescriptionList isHorizontal>
    2772              :                      { entry("flag", String(values.flag)) }
    2773              :                      { values.flag && entry("text", values.text) }
    2774            1 : +                    { entry("text2", values.text2) }
    2775              :                      { entry("radio", values.radio) }
    2776              :                      { entry("dropdown", values.dropdown) }
    2777              :                      { entry("color", values.color.red + "/" + values.color.green + "/" + values.color.blue) }
    2778              :                      { entry("list", values.list.join("/")) }
    2779              : -                    { entry("async", values.async.map(n => n.name + ":" + String(n._length_cache[n.name])).join("/")) }
    2780            1 : +                    { entry("async", values.async.map(n => n.name + ":" + String(n._length)).join("/")) }
    2781              :                      { entry("asyncVals", String(asyncValidations - asyncValidationsBase)) }
    2782            1 : +                    { entry("asyncUps", String(asyncUpdates - asyncUpdatesBase)) }
    2783            1 : +                    { entry("asyncCancels", String(asyncCancels - asyncCancelsBase)) }
    2784              :                      { entry("alternative", JSON.stringify(values.alternative)) }
    2785              :                  </DescriptionList>
    2786              :              }
    2787              : @@ -493,8 +607,10 @@ interface AsyncExampleValues {
    2788              :  
    2789              :  const AsyncExampleDialog = ({
    2790              :      throwError = 0,
    2791            1 : +    cancelCallback = null,
    2792              :  } : {
    2793              :      throwError?: number,
    2794              : +    cancelCallback?: null | (() => void),
    2795              :  }) => {
    2796              :      const Dialogs = useDialogs();
    2797              :  
    2798              : @@ -519,6 +635,10 @@ const AsyncExampleDialog = ({
    2799              :      const dlg = useDialogState_async(init, validate);
    2800              :  
    2801              :      async function apply() {
    2802            1 : +        cockpit.assert(dlg instanceof DialogState);
    2803              : +
    2804            1 : +        dlg.set_cancel(cancelCallback);
    2805              : +
    2806              :          await async_sleep(1000);
    2807              :          Dialogs.close();
    2808              :      }
    2809              : @@ -570,6 +690,7 @@ const AsyncExampleDialog = ({
    2810              :  
    2811              :  const SimpleExampleButtons = () => {
    2812              :      const Dialogs = useDialogs();
    2813            2 : +    const [cancelled, setCancelled] = useState(false);
    2814              :  
    2815              :      return (
    2816              :          <>
    2817              : @@ -581,10 +702,18 @@ const SimpleExampleButtons = () => {
    2818              :              </Button>
    2819              :              <Button
    2820              :                  id="open-async"
    2821              : -                onClick={() => Dialogs.show(<AsyncExampleDialog />)}
    2822            2 : +                onClick={
    2823            1 : +                    () => {
    2824            1 : +                        setCancelled(false);
    2825            1 : +                        Dialogs.show(<AsyncExampleDialog cancelCallback={() => setCancelled(true)} />);
    2826            1 : +                    }
    2827              : +                }
    2828              :              >
    2829              :                  Open async dialog
    2830              :              </Button>
    2831            2 : +            <div id="cancelled">
    2832            1 : +                Cancelled: {cancelled ? "yes" : "no"}
    2833            2 : +            </div>
    2834              :              <Button
    2835              :                  id="open-error"
    2836              :                  onClick={() => Dialogs.show(<AsyncExampleDialog throwError={1} />)}
    2837              : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
    2838              : index d5c37fad7..1c2b64a1f 100644
    2839              : --- a/test/common/dialoglib.py
    2840              : +++ b/test/common/dialoglib.py
    2841              : @@ -65,11 +65,12 @@ def css_escape(x: str) -> str:
    2842              :  
    2843              :  
    2844              :  class DialogHelpers:
    2845              : -    def __init__(self, b: testlib.Browser):
    2846              : +    def __init__(self, b: testlib.Browser, pfx: str):
    2847              :          self.browser = b
    2848              : +        self.pfx = pfx
    2849              :  
    2850              :      def id(self, path: str, tag: str) -> str:
    2851              : -        return f"#dialog-{tag}-{css_escape(path)}"
    2852              : +        return f"{self.pfx} [data-ouia-component-id='dialog-{tag}-{path}']"
    2853              :  
    2854              :      def field(self, path: str) -> str:
    2855              :          return self.id(path, "field")
    2856              : @@ -78,13 +79,13 @@ class DialogHelpers:
    2857              :          return self.id(path, "helper-text")
    2858              :  
    2859              :      def error(self) -> str:
    2860              : -        return "#dialog-error-message"
    2861              : +        return f"{self.pfx} [data-ouia-component-id='dialog-error-message']"
    2862              :  
    2863              :      def apply_button(self) -> str:
    2864              : -        return "#dialog-apply"
    2865              : +        return f"{self.pfx} [data-ouia-component-id='dialog-apply']"
    2866              :  
    2867              :      def cancel_button(self) -> str:
    2868              : -        return "#dialog-cancel"
    2869              : +        return f"{self.pfx} [data-ouia-component-id='dialog-cancel']"
    2870              :  
    2871              :      # TextInput
    2872              :  
    2873              : @@ -131,3 +132,14 @@ class DialogHelpers:
    2874              :  
    2875              :      def set_DropdownSelect(self, path: str, val: str) -> None:
    2876              :          self.browser.select_from_dropdown(self.field(path), val)
    2877              : +
    2878              : +    # FileChooserInput
    2879              : +
    2880              : +    def get_FileChooserInput(self, path: str) -> str:
    2881              : +        return self.browser.val(self.field(path) + " input")
    2882              : +
    2883              : +    def wait_FileChooserInput(self, path: str, val: str):
    2884              : +        self.browser.wait_val(self.field(path) + " input", val)
    2885              : +
    2886              : +    def set_FileChooserInput(self, path: str, val: str) -> None:
    2887              : +        self.browser.set_input_text(self.field(path) + " input", val)
    2888              : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
    2889              : index 7edd39e66..c3ee64106 100755
    2890              : --- a/test/verify/check-dialog
    2891              : +++ b/test/verify/check-dialog
    2892              : @@ -11,7 +11,7 @@ class TestDialog(testlib.MachineCase):
    2893              :  
    2894              :      def test(self):
    2895              :          b = self.browser
    2896              : -        d = dialoglib.DialogHelpers(b)
    2897              : +        d = dialoglib.DialogHelpers(b, "#dialog")
    2898              :  
    2899              :          # Missing coverage:
    2900              :          #
    2901              : @@ -86,26 +86,74 @@ class TestDialog(testlib.MachineCase):
    2902              :  
    2903              :          b.click("#open")
    2904              :          d.wait_DropdownSelect("dropdown", "one")
    2905              : -        d.set_DropdownSelect("dropdown", "two")
    2906              : +        d.set_DropdownSelect("dropdown", "three")  # first call to set_async
    2907              : +        d.set_DropdownSelect("dropdown", "two")  # second call, will cancel first
    2908              :          self.assertEqual(d.get_DropdownSelect("dropdown"), "two")
    2909              :          b.wait_in_text(d.helper_text("dropdown"), "discount")
    2910              :          b.click(d.apply_button())
    2911              :          b.wait_not_present("#dialog")
    2912              :  
    2913              :          b.wait_text("#dropdown", "two")
    2914              : +        b.wait_text("#text2", "two")
    2915              :  
    2916              : -        # DialogDropdownSelectObject, with update_func
    2917              : +        # Cancelling of irrelevant validations
    2918              :  
    2919              :          b.click("#open")
    2920              : +        d.set_DropdownSelect("dropdown", "three")
    2921              : +        d.wait_TextInput("text2", "three")  # wait for async update to be done
    2922              : +        b.click(d.apply_button())
    2923              : +        b.wait_in_text(d.helper_text("text3"), "Can't be empty")
    2924              : +        # start a debounced validation of text3 and remove it from the
    2925              : +        # dialog before it has finished.
    2926              : +        d.set_TextInput("text3", "x")
    2927              : +        time.sleep(0.5)
    2928              : +        d.set_TextInput("text3", "")
    2929              : +        d.set_DropdownSelect("dropdown", "one")
    2930              : +        b.click(d.apply_button())
    2931              : +        b.wait_not_present("#dialog")
    2932              : +
    2933              : +        # DialogDropdownSelectObject, with asynchronous updates
    2934              : +
    2935              : +        b.click("#open")
    2936              : +        d.set_Checkbox("flag", val=True)
    2937              :          d.wait_DropdownSelect("color", "red")
    2938              :          self.assertEqual(d.get_TextInput("text"), "")
    2939              :          d.set_DropdownSelect("color", "green")
    2940              :          self.assertEqual(d.get_DropdownSelect("color"), "green")
    2941              : -        self.assertEqual(d.get_TextInput("text"), "green")
    2942              : +        # Text does not react immediately.
    2943              : +        self.assertEqual(d.get_TextInput("text"), "")
    2944              : +        # Wait a bit and then change color again. This cancels the update.
    2945              : +        time.sleep(1)
    2946              : +        d.set_DropdownSelect("color", "blue")
    2947              : +        self.assertEqual(d.get_DropdownSelect("color"), "blue")
    2948              : +        self.assertEqual(d.get_TextInput("text"), "")
    2949              : +        # Apply while the update is still running. It should finish (1 update)
    2950              :          b.click(d.apply_button())
    2951              :          b.wait_not_present("#dialog")
    2952              :  
    2953              : -        b.wait_text("#color", "0/1/0")
    2954              : +        b.wait_text("#text", "blue")
    2955              : +        b.wait_text("#color", "0/0/1")
    2956              : +        b.wait_text("#asyncUps", "1")
    2957              : +        b.wait_text("#asyncCancels", "1")
    2958              : +
    2959              : +        # Cancelling of asynchronous tasks when the dialog is
    2960              : +        # cancelled
    2961              : +
    2962              : +        b.click("#open")
    2963              : +        d.wait_DropdownSelect("color", "red")
    2964              : +        self.assertEqual(d.get_TextInput("text"), "")
    2965              : +        d.set_DropdownSelect("color", "green")
    2966              : +        self.assertEqual(d.get_DropdownSelect("color"), "green")
    2967              : +        # Text does not react immediately.
    2968              : +        self.assertEqual(d.get_TextInput("text"), "")
    2969              : +        b.click(d.cancel_button())
    2970              : +        b.wait_not_present("#dialog")
    2971              : +
    2972              : +        # Wait for update to definitely be done if it wouldn't have
    2973              : +        # been cancelled
    2974              : +        time.sleep(3)
    2975              : +        b.wait_text("#asyncUps", "0")
    2976              : +        b.wait_text("#asyncCancels", "1")
    2977              :  
    2978              :          # List of DialogTextInputs
    2979              :  
    2980              : @@ -126,11 +174,12 @@ class TestDialog(testlib.MachineCase):
    2981              :          d.wait_TextInput("list.1", "bar")
    2982              :          b.wait_not_present(d.field("list.2"))
    2983              :          b.click(d.id("list", "add"))
    2984              : -        d.set_TextInput("list.2", "baz")
    2985              : +        d.set_TextInput("list.2", "magic")
    2986              : +        d.wait_TextInput("text", "magic")
    2987              :          b.click(d.apply_button())
    2988              :          b.wait_not_present("#dialog")
    2989              :  
    2990              : -        b.wait_text("#list", "foo/bar/baz")
    2991              : +        b.wait_text("#list", "foo/bar/magic")
    2992              :  
    2993              :          # Debounced and asynchronous validation
    2994              :  
    2995              : @@ -285,6 +334,9 @@ class TestDialog(testlib.MachineCase):
    2996              :          b.click("#open-async")
    2997              :          d.set_TextInput("text", "1234")
    2998              :          b.click(d.apply_button())
    2999              : +        b.wait_text("#cancelled", "Cancelled: no")
    3000              : +        b.click(d.cancel_button())
    3001              : +        b.wait_text("#cancelled", "Cancelled: yes")
    3002              :          b.set_input_text(d.field("text"), "", value_check=False)
    3003              :          d.wait_TextInput("text", "1234")
    3004              :          b.wait_not_present("#dialog")
    3005              : @@ -301,6 +353,250 @@ class TestDialog(testlib.MachineCase):
    3006              :          b.click(d.cancel_button())
    3007              :          b.wait_not_present("#dialog")
    3008              :  
    3009              : +    def testFileChooser(self):
    3010              : +        b = self.browser
    3011              : +        m = self.machine
    3012              : +        d = dialoglib.DialogHelpers(b, "#dialog")
    3013              : +        df = dialoglib.DialogHelpers(b, ".file-chooser")
    3014              : +
    3015              : +        # Inject a mock xdg-user-dir utility.
    3016              : +
    3017              : +        self.write_file("/usr/local/bin/xdg-user-dir",
    3018              : +"""#! /bin/sh
    3019              : +echo $HOME/Downloads
    3020              : +""", perm="a+x")
    3021              : +
    3022              : +        # Where our test files are. This is intended to be the same as
    3023              : +        # self.vm_tmpdir, but it is also hard-coded into
    3024              : +        # pkg/playground/dialog.tsx and so we hard-code it here as
    3025              : +        # well.
    3026              : +
    3027              : +        test_files = "/var/lib/cockpittest"
    3028              : +
    3029              : +        self.login_and_go("/playground/dialog", superuser=False)
    3030              : +
    3031              : +        b.click("#open")
    3032              : +
    3033              : +        # Use the get_FileChooserInput method so that Vulture doesn't
    3034              : +        # complain about it being unused.
    3035              : +
    3036              : +        self.assertEqual(d.get_FileChooserInput("file"), "")
    3037              : +
    3038              : +        # The first open has a empty Recent tab.
    3039              : +
    3040              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3041              : +        b.wait_in_text(".file-chooser-listing-body", "No recent files")
    3042              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    3043              : +        b.wait_not_present(".file-chooser")
    3044              : +
    3045              : +        # Basic interaction with the text input
    3046              : +
    3047              : +        d.set_FileChooserInput("file", "/home/non-existent/foo")
    3048              : +        b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
    3049              : +
    3050              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3051              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    3052              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    3053              : +        b.wait_not_present(".file-chooser")
    3054              : +
    3055              : +        m.upload(["verify/files/file-chooser-test/"], test_files)
    3056              : +        m.execute(f"mkdir '{test_files}/file-chooser-test/empty'")
    3057              : +        d.set_FileChooserInput("file", test_files)
    3058              : +        b.wait_in_text(d.helper_text("file"), "directory")
    3059              : +
    3060              : +        def file(name):
    3061              : +            return f".file-chooser-listing-body tr[data-name='{name}']"
    3062              : +
    3063              : +        # Navigate to empty directory
    3064              : +
    3065              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3066              : +        b.wait_visible(".file-chooser")
    3067              : +        b.mouse(file("cockpittest"), "dblclick")
    3068              : +        b.mouse(file("file-chooser-test"), "dblclick")
    3069              : +        b.mouse(file("empty"), "dblclick")
    3070              : +        b.wait_in_text(".file-chooser-listing-body", "Directory is empty")
    3071              : +
    3072              : +        # Go up and choose tmpdir/file-chooser-test/foo
    3073              : +
    3074              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
    3075              : +        b.assert_pixels(".file-chooser", "basic")
    3076              : +        b.mouse(file("foo"), "click")
    3077              : +        b.click(df.apply_button())
    3078              : +
    3079              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    3080              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    3081              : +
    3082              : +        # "foo" should now be in "Recent"
    3083              : +
    3084              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3085              : +        b.wait_visible(".file-chooser-listing-breadcrumbs nav")
    3086              : +        b.wait_visible(file("foo"))
    3087              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    3088              : +        b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
    3089              : +        b.wait_visible(file("foo"))
    3090              : +        b.wait_in_text(file("foo"), test_files + "/file-chooser-test")
    3091              : +        b.mouse(file("foo"), "click")
    3092              : +        b.click(df.apply_button())
    3093              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    3094              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    3095              : +
    3096              : +        # Check that "Home" has some expected files
    3097              : +
    3098              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3099              : +        b.click(".file-chooser-sidebar tr:contains('Home')")
    3100              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadmin")
    3101              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
    3102              : +        b.mouse(file("admin"), "dblclick")
    3103              : +        b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files")
    3104              : +        b.click(".file-chooser-listing-body button:contains('Show hidden files')")
    3105              : +        b.mouse(file(".ssh"), "dblclick")
    3106              : +        b.mouse(file("authorized_keys"), "click")
    3107              : +        b.click(df.apply_button())
    3108              : +
    3109              : +        d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
    3110              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    3111              : +
    3112              : +        # Check the "Downloads" shortcut
    3113              : +
    3114              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3115              : +        b.click(".file-chooser-sidebar tr:contains('Downloads')")
    3116              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads")
    3117              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    3118              : +
    3119              : +        # Check that we can't read /root
    3120              : +
    3121              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    3122              : +        b.mouse(file("root"), "dblclick")
    3123              : +        b.wait_in_text(".file-chooser-listing-body", "Permission denied")
    3124              : +        b.assert_pixels(".file-chooser", "denied")
    3125              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    3126              : +        b.wait_not_present(".file-chooser")
    3127              : +
    3128              : +        # Free text filtering
    3129              : +
    3130              : +        d.set_FileChooserInput("file", "")
    3131              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3132              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    3133              : +        b.mouse(file("file-chooser-test"), "dblclick")
    3134              : +
    3135              : +        b.wait_visible(file("bar"))
    3136              : +        b.wait_visible(file("foo"))
    3137              : +        b.wait_visible(file("foobar"))
    3138              : +
    3139              : +        b.set_input_text(".file-chooser-listing-header input", "fo")
    3140              : +        b.wait_visible(file("foo"))
    3141              : +        b.wait_not_present(file("bar"))
    3142              : +        b.wait_visible(file("foobar"))
    3143              : +        b.assert_pixels(".file-chooser", "filtered")
    3144              : +
    3145              : +        b.set_input_text(".file-chooser-listing-header input", "ba")
    3146              : +        b.wait_not_present(file("foo"))
    3147              : +        b.wait_visible(file("bar"))
    3148              : +        b.wait_visible(file("foobar"))
    3149              : +
    3150              : +        b.set_input_text(".file-chooser-listing-header input", "x")
    3151              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    3152              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    3153              : +
    3154              : +        b.wait_visible(file("bar"))
    3155              : +        b.wait_visible(file("foo"))
    3156              : +        b.wait_visible(file("foobar"))
    3157              : +
    3158              : +        # Prepared filtering.
    3159              : +
    3160              : +        # "No dots" was already active all the time, switch it off to
    3161              : +        # reveal more files.
    3162              : +
    3163              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    3164              : +
    3165              : +        b.wait_visible(file("bar"))
    3166              : +        b.wait_visible(file("foo"))
    3167              : +        b.wait_visible(file("foobar"))
    3168              : +        b.wait_visible(file("dots.txt"))
    3169              : +        b.wait_visible(file("only.dots"))
    3170              : +
    3171              : +        b.mouse(file("only.dots"), "dblclick")
    3172              : +        b.wait_visible(file("one.dot"))
    3173              : +        b.wait_visible(file("two.dots"))
    3174              : +
    3175              : +        b.click(".file-chooser-listing-header button:contains('No dots')")
    3176              : +
    3177              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    3178              : +
    3179              : +        # Filter even more, this should get cleared as well
    3180              : +        b.set_input_text(".file-chooser-listing-header input", "x")
    3181              : +
    3182              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    3183              : +
    3184              : +        b.wait_visible(file("one.dot"))
    3185              : +        b.wait_visible(file("two.dots"))
    3186              : +
    3187              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    3188              : +        b.wait_not_present(".file-chooser")
    3189              : +
    3190              : +        # Test the collection
    3191              : +
    3192              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3193              : +        b.click(".file-chooser-sidebar tr:contains('Some files')")
    3194              : +        b.wait_visible(file("Pictures"))
    3195              : +        b.wait_in_text(file("Pictures"), "/home/admin")
    3196              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    3197              : +        b.wait_visible(file("id_rsa"))
    3198              : +        b.wait_in_text(file("id_rsa"), "/home/admin/.ssh")
    3199              : +        b.click(file("id_rsa"))
    3200              : +        b.click(df.apply_button())
    3201              : +
    3202              : +        d.wait_FileChooserInput("file", "/home/admin/.ssh/id_rsa")
    3203              : +        b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
    3204              : +
    3205              : +        # Become superuser and access /root/.ssh
    3206              : +
    3207              : +        b.become_superuser()
    3208              : +
    3209              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3210              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    3211              : +        b.select_PF(".file-chooser-kebab", "Show hidden files")
    3212              : +        b.mouse(file("root"), "dblclick")
    3213              : +        b.mouse(file(".ssh"), "dblclick")
    3214              : +        b.mouse(file("authorized_keys"), "click")
    3215              : +        b.click(df.apply_button())
    3216              : +        d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
    3217              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    3218              : +
    3219              : +        # Select a directory
    3220              : +
    3221              : +        d.wait_FileChooserInput("dir", "")
    3222              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    3223              : +
    3224              : +        b.wait_in_text(".file-chooser-listing-body", "No recent directories")
    3225              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    3226              : +        b.click(file("file-chooser-test"))
    3227              : +        b.click(df.apply_button())
    3228              : +
    3229              : +        d.wait_FileChooserInput("dir", test_files + "/file-chooser-test")
    3230              : +
    3231              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    3232              : +        b.wait_visible(file("file-chooser-test"))
    3233              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    3234              : +        b.wait_visible(file("file-chooser-test"))
    3235              : +        b.wait_not_present(file("foo"))
    3236              : +        b.wait_in_text(file("file-chooser-test"), test_files)
    3237              : +
    3238              : +        b.wait_visible(df.apply_button() + "[aria-disabled=true]")
    3239              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    3240              : +        b.wait_not_present(".file-chooser")
    3241              : +
    3242              : +        # Check mobile layout
    3243              : +
    3244              : +        b.set_layout("mobile")
    3245              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    3246              : +        b.wait_not_visible(".file-chooser-sidebar")
    3247              : +        b.wait_visible(".file-chooser-kebab")
    3248              : +
    3249              : +        b.select_PF(".file-chooser-kebab", "Test files")
    3250              : +        b.mouse(file("file-chooser-test"), "dblclick")
    3251              : +        b.wait_visible(file("empty"))
    3252              : +
    3253              :  
    3254              :  if __name__ == '__main__':
    3255              :      testlib.test_main()
    3256              : diff --git a/test/verify/check-networkmanager-wifi b/test/verify/check-networkmanager-wifi
    3257              : index 79374d517..35c9aaa72 100755
    3258              : --- a/test/verify/check-networkmanager-wifi
    3259              : +++ b/test/verify/check-networkmanager-wifi
    3260              : @@ -87,7 +87,7 @@ wpa_passphrase=hidden99""")
    3261              :      def testWifi(self):
    3262              :          m = self.machine
    3263              :          b = self.browser
    3264              : -        d = DialogHelpers(b)
    3265              : +        d = DialogHelpers(b, "#network-wifi-connect-dialog")
    3266              :  
    3267              :          self.login_and_go("/network")
    3268              :          b.wait_visible("#networking")
    3269              : @@ -324,7 +324,7 @@ wpa_passphrase=hidden99""")
    3270              :  
    3271              :      def testHidden(self):
    3272              :          b = self.browser
    3273              : -        d = DialogHelpers(b)
    3274              : +        d = DialogHelpers(b, "#network-wifi-connect-dialog")
    3275              :  
    3276              :          self.login_and_go("/network")
    3277              :          b.wait_visible("#networking")
    3278              : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
    3279              : new file mode 100644
    3280              : index 000000000..de345c341
    3281              : --- /dev/null
    3282              : +++ b/test/verify/files/file-chooser-test/bar
    3283              : @@ -0,0 +1 @@
    3284              : +Nothing to see.
    3285              : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
    3286              : new file mode 100644
    3287              : index 000000000..0aadcf89b
    3288              : --- /dev/null
    3289              : +++ b/test/verify/files/file-chooser-test/dots.txt
    3290              : @@ -0,0 +1 @@
    3291              : +A file with a dot in its name.
    3292              : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
    3293              : new file mode 100644
    3294              : index 000000000..8159b424a
    3295              : --- /dev/null
    3296              : +++ b/test/verify/files/file-chooser-test/foo
    3297              : @@ -0,0 +1 @@
    3298              : +A file of no consequence.
    3299              : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
    3300              : new file mode 100644
    3301              : index 000000000..896416923
    3302              : --- /dev/null
    3303              : +++ b/test/verify/files/file-chooser-test/foobar
    3304              : @@ -0,0 +1 @@
    3305              : +Can't you think of any other names?
    3306              : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
    3307              : new file mode 100644
    3308              : index 000000000..e69de29bb
    3309              : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
    3310              : new file mode 100644
    3311              : index 000000000..e69de29bb
        

Generated by: LCOV version 2.0-1