LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 96.2 % 900 866
Test Date: 2026-06-16 14:09:37

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

Generated by: LCOV version 2.0-1