LCOV - code coverage report
Current view: top level - lcov - github-pr.diff Coverage Total Hit
Test: cockpit Lines: 96.7 % 860 832
Test Date: 2026-07-13 10:00:01

            Line data    Source code
       1              : diff --git a/pkg/lib/cockpit/dialog.tsx b/pkg/lib/cockpit/dialog.tsx
       2              : index a2c78dd14..b62c7d892 100644
       3              : --- a/pkg/lib/cockpit/dialog.tsx
       4              : +++ b/pkg/lib/cockpit/dialog.tsx
       5              : @@ -165,7 +165,7 @@
       6              :     and methods.
       7              :  
       8              :     - handle = dlg.field(name)
       9              : -   - handle = dlg.field(name, update_func)
      10              : +   - handle = dlg.field(name, changed_func)
      11              :  
      12              :     This returns a handle for a specific field of the dialog
      13              :     values. Using handles like this becomes convenient when there are
      14              : @@ -173,21 +173,22 @@
      15              :     also work well with TypeScript. For simple dialogs they might feel
      16              :     a bit clunky.
      17              :  
      18              : -   The second argument, "update_func", is optional. If given, it
      19              : +   The second argument, "changed_func", is optional. If given, it
      20              :     should be a function and that function will be called whenever the
      21              :     dialog value is changed via the returned handle (and the returned
      22              : -   handle only).
      23              : +   handle only).  It might also be called some time later, when
      24              : +   "set_debounced" is used.
      25              :  
      26              :     - handle = dlg.top()
      27              : -   - handle = dlg.top(update_func)
      28              : +   - handle = dlg.top(changed_func)
      29              :  
      30              :     Get a handle for the whole value object.  The usual
      31              :     "dlg.field(name)" call is actually just a shortcut for
      32              :     "dlg.top().sub(name)".  But since that looks quite obscure in
      33              :     simple dialogs that only have one level of values, we have the
      34              :     "dlg.field(name)" shortcut as well.  This whole-value handle is
      35              : -   useful for "dlg.top().at(...)", see below, or for update
      36              : -   notifications that trigger for each and every change.
      37              : +   useful for "dlg.top().at(...)", see below, or for notifications
      38              : +   that trigger for each and every change.
      39              :  
      40              :     - dlg.values
      41              :  
      42              : @@ -214,23 +215,41 @@
      43              :     dialog, and do input validation as necessary and all the other
      44              :     things that you don't need to think about.
      45              :  
      46              : +   This is actually the same as "handle.set_debounced" with a delay of
      47              : +   0, see below.
      48              : +
      49              : +   - handle.set_debounced(val)
      50              : +   - handle.set_debounced(val, delay)
      51              : +
      52              : +   Set the current value of a value handle immediately as described
      53              : +   above for "handle.set", and render the dialog.  But validation and
      54              : +   calling of the "changed" functions happens only after "delay"
      55              : +   milliseconds.
      56              : +
      57              : +   When "delay" is omitted, it defaults to a sensible value for
      58              : +   debouncing keyboard input.
      59              : +
      60              : +   - handle.notify(changed_func)
      61              : +
      62              : +   Return a new handle for the same dialog field that "handle"
      63              : +   represents, but call "changed_func" whenever the field is changed
      64              : +   via the new handle.
      65              : +
      66              :     - handle.sub(name_or_index)
      67              : -   - handle.sub(name_or_index, update_func)
      68              : +   - handle.sub(name_or_index, changed_func)
      69              :  
      70              :     Get a handle for a nested value. When the current value is an
      71              :     object, you should pass the name of a nested field. If it is an
      72              :     array, pass the index of the desired element.  See "dlg.field()"
      73              :     above for more information about handles.
      74              :  
      75              : -   - handle.get_async(debounce, (val, signal) => ...)
      76              : -   - handle.set_async(debounce, (val, signal) => new_val)
      77              : +   - handle.get_async((val, signal) => ...)
      78              : +   - handle.set_async((val, signal) => new_val)
      79              :  
      80              : -   These are for running debounced, asynchronous code.  Both functions
      81              : -   will run the given function after "debounce" milliseconds, but only
      82              : -   if the value of the field hasn't changed in the meantime.  The
      83              : -   dialog waits for all asynchronous tasks started by these functions
      84              : -   to be finished before running the action function.  When the dialog
      85              : -   is cancelled, they all get cancelled.
      86              : +   These are for running asynchronous code.  The dialog waits for all
      87              : +   asynchronous tasks started by these functions to be finished before
      88              : +   running the action function.  When the dialog is cancelled, they
      89              : +   all get cancelled.
      90              :  
      91              :     The return value of "handle.set_async" is made the new value of the
      92              :     field, but only if the value of the field hasn't changed in the
      93              : @@ -241,8 +260,7 @@
      94              :  
      95              :     The "handle.get_async" function is a slight variation on this. It
      96              :     is meant to perform asynchronous computations that do not modify
      97              : -   the field value itself, but have some other side effects.  Maybe
      98              : -   they modify multiple other field values or some React state. There
      99              : +   the field value itself, but have some other side effects.  There
     100              :     can be more than one call active at a given time. They only get
     101              :     cancelled when the value of the field changes.
     102              :  
     103              : @@ -257,17 +275,6 @@
     104              :     features of a AbortSignal, of course, such as setting
     105              :     "signal.onabort", adding event listeners, etc.
     106              :  
     107              : -   As an example, here is how you might implement set_async on top of
     108              : -   get_async:
     109              : -
     110              : -      function set_async(handle, debounce, func) {
     111              : -          handle.get_async(debounce, (val, signal) => {
     112              : -              const new_val = await func(val, signal);
     113              : -              if (!signal.aborted)
     114              : -                  handle.set(new_val);
     115              : -          })
     116              : -      }
     117              : -
     118              :     - handle.at(witness)
     119              :  
     120              :     Get a handle with a narrowed type for "handle".  The new handle
     121              : @@ -293,7 +300,7 @@
     122              :     It is important to use this function instead of just "handle.set()"
     123              :     with an appropriately modified array. By using this function, the
     124              :     plumbing is able to keep its internal state in synch, which is
     125              : -   especially important for asynchronous validation and update
     126              : +   especially important for asynchronous validation and "changed"
     127              :     functions.
     128              :  
     129              :     However, it is okay to just replace an array with a different
     130              : @@ -416,16 +423,11 @@
     131              :     then you can modify field values via calls to "handle.set". (Be
     132              :     careful not to create endless validation loops!)
     133              :  
     134              : -   - handle.validate_async(debounce, async (v, task) >= ...)
     135              : +   - handle.validate_async(async (v, task) >= ...)
     136              :  
     137              : -   Calls the given async function "debounce" milliseconds after the
     138              : -   value represented by the handle has last been changed. (Or
     139              : -   immediately when the apply button is clicked.)  When the function
     140              : -   throws an exception, the validation is considered to have been
     141              : -   successful.
     142              : -
     143              : -   See the documentation for "handle.validate" above for more rules
     144              : -   that apply to validation functions.
     145              : +   Same as "handle.validate", but the function is asynchronous and
     146              : +   "dialog.run_action" will wait for these functions to complete
     147              : +   before actually carrying out the dialog action.
     148              :  
     149              :     UPDATES
     150              :  
     151              : @@ -443,28 +445,28 @@
     152              :  
     153              :     It's okay and simplest to just put that code right next to the call
     154              :     to "handler.set()".  If that call is in a porcelain component (as
     155              : -   it probably often will be), you can pass a "update_func" when
     156              : +   it probably often will be), you can pass a "changed_func" when
     157              :     creating the handle for that porcelain component with
     158              :     "handler.sub()" or "dialog.field()".  For example:
     159              :  
     160              : -       function on_plate_change(val: string) {
     161              : +       function plate_changed(val: string) {
     162              :             console.log("NEW LICENSE PLATE", val);
     163              :         }
     164              :  
     165              :         return (
     166              :             <DialogTextInput
     167              :                 label="License plate number"
     168              : -               field={dlg.field("plate", on_plate_change)}
     169              : +               field={dlg.field("plate", plate_changed)}
     170              :             />
     171              :         );
     172              :  
     173              : -   The function "on_plate_change" will be called whenever the user
     174              : -   changes the "plate" field via the DialogTextInput.  The
     175              : -   "on_plate_change" function will not be called when the "plate" is
     176              : -   changed in other places.  If that should happen, you have to
     177              : -   arrange for it explicitly.
     178              : +   The function "plate_changed" will be called whenever the user
     179              : +   changes the "plate" field via the DialogTextInput (subject to
     180              : +   debouncing).  The "plate_changed" function will not be called
     181              : +   when the "plate" is changed in other places.  If that should
     182              : +   happen, you have to arrange for it explicitly.
     183              :  
     184              : -   Functions like "on_plate_change" can and should modify the dialog
     185              : +   Functions like "plate_changed" can and should modify the dialog
     186              :     fields via calls to "handle.set()".
     187              :  
     188              :     If you want to run asynchronous code, you can do so with
     189              : @@ -472,8 +474,8 @@
     190              :     want to asynchronously fetch the car model for a given license
     191              :     plate from a database, you can do it like this:
     192              :  
     193              : -       function on_plate_change(val: string) {
     194              : -           dlg.field("model").set_async(1000, async () => await fetch_model(val));
     195              : +       function plate_changed(val: string) {
     196              : +           dlg.field("model").set_async(async () => await fetch_model(val));
     197              :         }
     198              :  
     199              :     When arrays are involved, dialog fields can move around while your
     200              : @@ -688,17 +690,20 @@ export class DialogField<T> {
     201              :      /* eslint-enable */
     202              :      #getter: () => T;
     203              :      #setter: (val: T) => void;
     204            4 : +    #trigger: () => void;
     205              :  
     206              :      constructor(
     207              :          dialog: DialogState<unknown>,
     208              :          state: DialogFieldState,
     209              :          getter: () => T,
     210              :          setter: (val: T) => void,
     211            4 : +        trigger: () => void,
     212              :      ) {
     213              :          this.#dialog = dialog;
     214              :          this.#state = state;
     215              :          this.#getter = getter;
     216              :          this.#setter = setter;
     217            4 : +        this.#trigger = trigger;
     218              :      }
     219              :  
     220              :      validation_text(): string | undefined {
     221              : @@ -710,8 +715,13 @@ export class DialogField<T> {
     222              :      }
     223              :  
     224              :      set(val: T): void {
     225            3 : +        this.set_debounced(val, 0);
     226            3 : +    }
     227              : +
     228            4 : +    set_debounced(val: T, delay?: number): void {
     229              :          this.#dialog._abort_state_tasks(this.#state, true);
     230              :          this.#setter(val);
     231            4 : +        this.#dialog._set_debounce(this.#state, delay === undefined ? 500 : delay, () => this.#trigger());
     232              :      }
     233              :  
     234              :      ouia_id(tag: string = "field"): string {
     235              : @@ -738,6 +748,7 @@ export class DialogField<T> {
     236              :          if (Array.isArray(val)) {
     237              :              const sub = this.#state.sub.get(index);
     238              :              if (sub) {
     239            1 : +                this.#dialog._cancel_debounce(sub);
     240              :                  this.#dialog._abort_state_tasks(sub);
     241              :                  sub.tag = -1;
     242              :              }
     243              : @@ -750,6 +761,7 @@ export class DialogField<T> {
     244              :              }
     245              :              this.#state.sub.delete(val.length - 1);
     246              :              this.#setter(toSpliced(val, index, 1) as T);
     247            1 : +            this.#trigger();
     248              :          }
     249              :      }
     250              :  
     251              : @@ -757,32 +769,56 @@ export class DialogField<T> {
     252              :          const val = this.get();
     253              :          if (Array.isArray(val)) {
     254              :              this.#setter(val.concat(item) as T);
     255            1 : +            this.#trigger();
     256              :          }
     257              :      }
     258              :  
     259              : -    sub<K extends keyof T>(tag: K, update_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
     260            2 : +    notify(changed_func: (val: T) => void): DialogField<T> {
     261            2 : +        return new DialogField<T>(
     262            2 : +            this.#dialog,
     263            2 : +            this.#state,
     264            2 : +            this.#getter,
     265            2 : +            this.#setter,
     266            1 : +            () => {
     267            1 : +                changed_func(this.#getter());
     268            1 : +                this.#trigger();
     269            1 : +            },
     270            2 : +        );
     271            2 : +    }
     272              : +
     273            4 : +    sub<K extends keyof T>(tag: K, changed_func?: ((val: T[K]) => void) | undefined): DialogField<T[K]> {
     274              :          const sub = this.#dialog._get_sub_state(this.#state, tag);
     275              : +
     276            4 : +        const getter = (): T[K] => {
     277            4 : +            const container = this.get();
     278            2 : +            if (Array.isArray(container) && typeof sub.tag == "number") {
     279            2 : +                return container[sub.tag];
     280            2 : +            } else {
     281            4 : +                return container[tag];
     282            4 : +            }
     283            4 : +        };
     284              : +
     285            4 : +        const setter = (val: T[K]) => {
     286            4 : +            const container = this.get();
     287            2 : +            if (Array.isArray(container) && typeof sub.tag == "number") {
     288            2 : +                this.#setter(toSpliced(container, sub.tag, 1, val) as T);
     289            2 : +            } else {
     290            4 : +                this.#setter({ ...container, [tag]: val });
     291            4 : +            }
     292            4 : +        };
     293              : +
     294            4 : +        const trigger = () => {
     295            4 : +            if (changed_func)
     296            3 : +                changed_func(getter());
     297            4 : +            this.#trigger();
     298            4 : +        };
     299              : +
     300              :          return new DialogField<T[K]>(
     301              :              this.#dialog,
     302              :              sub,
     303              : -            () => {
     304              : -                const container = this.get();
     305              : -                if (Array.isArray(container) && typeof sub.tag == "number") {
     306              : -                    return container[sub.tag];
     307              : -                } else {
     308              : -                    return container[tag];
     309              : -                }
     310              : -            },
     311              : -            (val) => {
     312              : -                const container = this.get();
     313              : -                if (Array.isArray(container) && typeof sub.tag == "number") {
     314              : -                    this.#setter(toSpliced(container, sub.tag, 1, val) as T);
     315              : -                } else {
     316              : -                    this.#setter({ ...container, [tag]: val });
     317              : -                }
     318              : -                if (update_func)
     319              : -                    update_func(val);
     320              : -            },
     321            4 : +            getter,
     322            4 : +            setter,
     323            4 : +            trigger,
     324              :          );
     325              :      }
     326              :  
     327              : @@ -796,23 +832,23 @@ export class DialogField<T> {
     328              :          this.#dialog._validate_value(this.#state, val, () => func(val));
     329              :      }
     330              :  
     331              : -    validate_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<DialogValidationResult<T>>): void {
     332            1 : +    validate_async(func: (val: T, signal: AbortSignal) => Promise<DialogValidationResult<T>>): void {
     333              :          const val = this.get();
     334              : -        this.#dialog._validate_value_async(this.#state, val, debounce, signal => func(val, signal));
     335            1 : +        this.#dialog._validate_value_async(this.#state, val, signal => func(val, signal));
     336              :      }
     337              :  
     338              : -    set_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<T>): void {
     339            2 : +    set_async(func: (val: T, signal: AbortSignal) => Promise<T>): void {
     340              :          const val = this.get();
     341              : -        this.#dialog._set_value_async(this.#state, debounce, async signal => {
     342            2 : +        this.#dialog._set_value_async(this.#state, async signal => {
     343              :              const new_val = await func(val, signal);
     344              :              if (!signal.aborted)
     345              :                  this.set(new_val);
     346              :          });
     347              :      }
     348              :  
     349              : -    get_async(debounce: number, func: (val: T, signal: AbortSignal) => Promise<void>): void {
     350            1 : +    get_async(func: (val: T, signal: AbortSignal) => Promise<void>): void {
     351              :          const val = this.get();
     352              : -        this.#dialog._get_value_async(this.#state, debounce, signal => func(val, signal));
     353            1 : +        this.#dialog._get_value_async(this.#state, signal => func(val, signal));
     354              :      }
     355              :  }
     356              :  
     357              : @@ -827,45 +863,25 @@ function get_validation_result_own_string(result: unknown): string | undefined {
     358              :  
     359              :  export class DialogTask {
     360              :      #name: string;
     361              : -    #timeout_id: number = 0;
     362              : -    #promise: Promise<void> | null = null;
     363              : -    #start: () => void;
     364              : -    #done: (task: DialogTask) => void;
     365            2 : +    #promise: Promise<void>;
     366              :      #controller: AbortController;
     367              :  
     368              :      constructor(
     369              :          name: string,
     370              : -        debounce: number,
     371              :          func: (task: DialogTask) => Promise<void>,
     372              :          done: (task: DialogTask) => void,
     373              :      ) {
     374            2 : +        debug("starting task", name);
     375              :          this.#name = name;
     376              : -        this.#done = done;
     377              : -        this.#start = () => {
     378              : -            debug("starting task", this.#name);
     379              : -            cockpit.assert(!this.#controller.signal.aborted);
     380              : -            this.#promise = func(this);
     381              : -            this.#promise.finally(() => {
     382              : -                debug("task done", this.#name);
     383              : -                done(this);
     384              : -            });
     385              : -        };
     386              : -        this.#timeout_id = window.setTimeout(this.#start, debounce);
     387              :          this.#controller = new AbortController();
     388              : -        debug("creating task", this.#name, debounce);
     389              : -    }
     390              : -
     391              : -    start_now() {
     392              : -        if (!this.#promise && !this.#controller.signal.aborted) {
     393              : -            debug("skipping debounce of task", this.#name);
     394              : -            window.clearTimeout(this.#timeout_id);
     395              : -            this.#start();
     396              : -        }
     397            2 : +        this.#promise = func(this);
     398            2 : +        this.#promise.finally(() => {
     399            2 : +            debug("task done", this.#name);
     400            2 : +            done(this);
     401            2 : +        });
     402              :      }
     403              :  
     404              :      async wait() {
     405              : -        // Waiting is only allowed for tasks that have actually been started.
     406              : -        cockpit.assert(this.#promise);
     407              :          debug("waiting for task", this.#name);
     408              :          await this.#promise;
     409              :      }
     410              : @@ -876,12 +892,7 @@ export class DialogTask {
     411              :  
     412              :      abort() {
     413              :          debug("aborting task", this.#name);
     414              : -        window.clearTimeout(this.#timeout_id);
     415              :          this.#controller.abort();
     416              : -        if (!this.#promise) {
     417              : -            debug("aborted task done", this.#name);
     418              : -            this.#done(this);
     419              : -        }
     420              :      }
     421              :  }
     422              :  
     423              : @@ -907,6 +918,9 @@ interface DialogFieldState {
     424              :      parent: DialogFieldState | null,
     425              :      tag: string | number | symbol;
     426              :      sub: Map<string | number | symbol, DialogFieldState>;
     427              : +    // debouncing
     428              : +    debounce_timer: number;
     429              : +    debounce_func: null | (() => void);
     430              :      // validation
     431              :      relevant: boolean;
     432              :      validation_text: string | undefined;
     433              : @@ -952,6 +966,8 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     434              :              parent: null,
     435              :              tag: "",
     436              :              sub: new Map(),
     437            4 : +            debounce_timer: 0,
     438            4 : +            debounce_func: null,
     439              :              relevant: false,
     440              :              validation_text: undefined,
     441              :              cached_value: undefined,
     442              : @@ -989,6 +1005,8 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     443              :                  parent: state,
     444              :                  tag,
     445              :                  sub: new Map(),
     446            4 : +                debounce_timer: 0,
     447            4 : +                debounce_func: null,
     448              :                  relevant: false,
     449              :                  validation_text: undefined,
     450              :                  cached_value: undefined,
     451              : @@ -1020,24 +1038,68 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     452              :          await visit(this.#top_state);
     453              :      }
     454              :  
     455              : +    /* DEBOUNCING
     456              : +     */
     457              : +
     458            4 : +    _cancel_debounce(state: DialogFieldState) {
     459            4 : +        if (state.debounce_timer) {
     460            4 : +            debug("cancelling debounce", state_path(state));
     461            4 : +            window.clearTimeout(state.debounce_timer);
     462            4 : +            state.debounce_timer = 0;
     463            4 : +        }
     464            4 : +    }
     465              : +
     466            4 : +    _set_debounce(state: DialogFieldState, delay: number, func: () => void) {
     467            4 : +        this._cancel_debounce(state);
     468              : +
     469            4 : +        if (delay === 0) {
     470            4 : +            debug("no debounce trigger", state_path(state));
     471            4 : +            func();
     472            4 : +        } else {
     473            4 : +            debug("debounce trigger", state_path(state), delay);
     474            4 : +            state.debounce_func = func;
     475            4 : +            state.debounce_timer = window.setTimeout(
     476            3 : +                () => {
     477            3 : +                    state.debounce_timer = 0;
     478            3 : +                    state.debounce_func = null;
     479            3 : +                    func();
     480            3 : +                },
     481            4 : +                delay,
     482            4 : +            );
     483            4 : +        }
     484            4 : +    }
     485              : +
     486            4 : +    _run_all_debouncing_now() {
     487            4 : +        this._for_each_field_state(state => {
     488            3 : +            if (state.debounce_timer) {
     489            3 : +                debug("running debounced trigger now", state_path(state));
     490            3 : +                cockpit.assert(state.debounce_func);
     491            3 : +                window.clearTimeout(state.debounce_timer);
     492            3 : +                state.debounce_timer = 0;
     493            3 : +                state.debounce_func();
     494            3 : +            }
     495            4 : +        });
     496            4 : +    }
     497              : +
     498              :      /* TASKS
     499              :  
     500              :         Tasks are a little abstraction that runs a asynchronous
     501              : -       function after a debounce timeout.  Before running the action
     502              : -       function, we need to wait for them all to finish.
     503              : +       function.  Before running the action function, we need to wait
     504              : +       for them all to finish.
     505              :       */
     506              :  
     507              :      async _run_all_tasks_now() {
     508              :          let awaited: boolean = false;
     509              :          do {
     510              : -            this._for_each_field_state(state => {
     511              : -                if (state.validation_task)
     512              : -                    state.validation_task.start_now();
     513              : -                if (state.update_task)
     514              : -                    state.update_task.start_now();
     515              : -                for (const task of state.update_tasks.values())
     516              : -                    task.start_now();
     517              : -            });
     518              : +            // Start all the things that might have been queued up for
     519              : +            // debouncing.
     520              : +
     521            4 : +            this._run_all_debouncing_now();
     522              : +
     523              : +            // Wait for the tasks that are currently running.  While
     524              : +            // waiting, new things might have been qeued up for
     525              : +            // debouncing or new tasks might have been started, so we
     526              : +            // have to go back to the beginning.
     527              :  
     528              :              awaited = false;
     529              :              await this._for_each_field_state_async(async state => {
     530              : @@ -1201,7 +1263,6 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     531              :      _validate_value_async(
     532              :          state: DialogFieldState,
     533              :          val: unknown,
     534              : -        debounce: number,
     535              :          func: (signal: AbortSignal) => Promise<unknown>
     536              :      ): void {
     537              :          state.relevant = true;
     538              : @@ -1213,7 +1274,6 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     539              :                  state.validation_task.abort();
     540              :              state.validation_task = new DialogTask(
     541              :                  state_path(state) + ":validate",
     542              : -                debounce,
     543              :                  async task => {
     544              :                      const signal = task.get_abort_signal();
     545              :                      let result;
     546              : @@ -1238,12 +1298,10 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     547              :  
     548              :      _set_value_async(
     549              :          state: DialogFieldState,
     550              : -        debounce: number,
     551              :          func: (signal: AbortSignal) => Promise<void>
     552              :      ): void {
     553              :          const task = new DialogTask(
     554              :              state_path(state) + ":set",
     555              : -            debounce,
     556              :              async task => {
     557              :                  try {
     558              :                      await func(task.get_abort_signal());
     559              : @@ -1264,12 +1322,10 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     560              :  
     561              :      _get_value_async(
     562              :          state: DialogFieldState,
     563              : -        debounce: number,
     564              :          func: (signal: AbortSignal) => Promise<void>
     565              :      ): void {
     566              :          const task = new DialogTask(
     567              :              state_path(state) + ":get",
     568              : -            debounce,
     569              :              async task => {
     570              :                  try {
     571              :                      await func(task.get_abort_signal());
     572              : @@ -1344,7 +1400,7 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     573              :          }
     574              :      }
     575              :  
     576              : -    top(update_func?: ((val: V) => void) | undefined): DialogField<V> {
     577            4 : +    top(changed_func?: ((values: V) => void) | undefined): DialogField<V> {
     578              :          return new DialogField<V>(
     579              :              this as DialogState<unknown>,
     580              :              this.#top_state,
     581              : @@ -1364,16 +1420,18 @@ export class DialogState<V> extends EventEmitter<DialogStateEvents> {
     582              :                  }
     583              :                  this.values = val;
     584              :                  this.#update();
     585            4 : +            },
     586            4 : +            () => {
     587              :                  if (this.#online_validation)
     588              :                      this.#trigger_validation();
     589              : -                if (update_func)
     590              : -                    update_func(val);
     591            4 : +                if (changed_func)
     592            2 : +                    changed_func(this.values);
     593              :              },
     594              :          );
     595              :      }
     596              :  
     597              : -    field<K extends keyof V>(tag: K, update_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
     598              : -        return this.top().sub(tag, update_func);
     599            4 : +    field<K extends keyof V>(tag: K, changed_func?: ((val: V[K]) => void) | undefined): DialogField<V[K]> {
     600            4 : +        return this.top().sub(tag, changed_func);
     601              :      }
     602              :  }
     603              :  
     604              : @@ -1635,6 +1693,7 @@ export const OptionalFormGroup = ({
     605              :  export const DialogTextInput = ({
     606              :      label = null,
     607              :      field,
     608            3 : +    debounce,
     609              :      excuse,
     610              :      warning,
     611              :      explanation,
     612              : @@ -1644,6 +1703,7 @@ export const DialogTextInput = ({
     613              :  } : {
     614              :      label?: React.ReactNode,
     615              :      field: DialogField<string>,
     616              : +    debounce?: number | undefined,
     617              :      excuse?: string | falsy,
     618              :      warning?: React.ReactNode,
     619              :      explanation?: React.ReactNode,
     620              : @@ -1656,7 +1716,7 @@ export const DialogTextInput = ({
     621              :                  id={fid}
     622              :                  ouiaId={field.ouia_id()}
     623              :                  value={field.get()}
     624              : -                onChange={(_event, val) => field.set(val)}
     625            2 : +                onChange={(_event, val) => field.set_debounced(val, debounce)}
     626              :                  isDisabled={!!excuse || isDisabled}
     627              :                  {...props}
     628              :              />
     629              : @@ -1668,6 +1728,7 @@ export const DialogTextInput = ({
     630              :  export const DialogPasswordInput = ({
     631              :      label = null,
     632              :      field,
     633            2 : +    debounce,
     634              :      excuse,
     635              :      warning,
     636              :      explanation,
     637              : @@ -1677,6 +1738,7 @@ export const DialogPasswordInput = ({
     638              :  } : {
     639              :      label?: React.ReactNode,
     640              :      field: DialogField<string>,
     641              : +    debounce?: number | undefined,
     642              :      excuse?: string | falsy,
     643              :      warning?: React.ReactNode,
     644              :      explanation?: React.ReactNode,
     645              : @@ -1693,7 +1755,7 @@ export const DialogPasswordInput = ({
     646              :                          ouiaId={field.ouia_id()}
     647              :                          type={visible ? "text" : "password"}
     648              :                          value={field.get()}
     649              : -                        onChange={(_event, value) => field.set(value)}
     650            2 : +                        onChange={(_event, value) => field.set_debounced(value, debounce)}
     651              :                          isDisabled={!!excuse || isDisabled}
     652              :                          {...props}
     653              :                      />
     654              : diff --git a/pkg/lib/cockpit/react/FileChooser.css b/pkg/lib/cockpit/react/FileChooser.css
     655              : new file mode 100644
     656              : index 000000000..0587bdfc7
     657              : --- /dev/null
     658              : +++ b/pkg/lib/cockpit/react/FileChooser.css
     659              : @@ -0,0 +1,85 @@
     660              : +/*
     661              : + * Copyright (C) 2026 Red Hat, Inc.
     662              : + * SPDX-License-Identifier: LGPL-2.1-or-later
     663              : + */
     664              : +
     665              : +.file-chooser-body {
     666              : +    display: grid;
     667              : +    grid-template-columns: minmax(15em, auto) 1fr;
     668              : +    grid-template-rows: auto auto 1fr;
     669              : +    column-gap: var(--pf-t--global--spacer--md);
     670              : +    row-gap: var(--pf-t--global--spacer--md);
     671              : +    block-size: 60ex;
     672              : +}
     673              : +
     674              : +.file-chooser-sidebar {
     675              : +    grid-column: 1 / 2;
     676              : +    grid-row: 1 / 4;
     677              : +    overflow-y: scroll;
     678              : +    border-inline-end: solid 2px var(--pf-t--global--background--color--disabled--default);
     679              : +    padding-inline-end: var(--pf-t--global--spacer--md);
     680              : +}
     681              : +
     682              : +.file-chooser-listing-header {
     683              : +    grid-column: 2 / 3;
     684              : +    grid-row: 1 / 2;
     685              : +}
     686              : +
     687              : +.file-chooser-listing-header > div {
     688              : +    block-size: 100%;
     689              : +}
     690              : +
     691              : +.file-chooser-listing-breadcrumbs {
     692              : +    grid-column: 2 / 3;
     693              : +    grid-row: 2 / 3;
     694              : +    /* align left of breadcrumb with left of table content */
     695              : +    padding-inline-start: var(--pf-t--global--spacer--inset--page-chrome);
     696              : +}
     697              : +
     698              : +.file-chooser-listing-body {
     699              : +    grid-column: 2 / 3;
     700              : +    grid-row: 3 / 4;
     701              : +    overflow-y: scroll;
     702              : +}
     703              : +
     704              : +@media (width < 768px) {
     705              : +    .file-chooser-body {
     706              : +        grid-template-columns: 0 1fr;
     707              : +    }
     708              : +
     709              : +    .file-chooser-hide-on-narrow {
     710              : +        display: none;
     711              : +    }
     712              : +}
     713              : +
     714              : +@media (width >= 768px) {
     715              : +    .file-chooser-hide-on-wide {
     716              : +        display: none;
     717              : +    }
     718              : +}
     719              : +
     720              : +.pf-v6-c-table tr.file-chooser-selected:where(.pf-v6-c-table__tr) > :where(th, td) {
     721              : +    background: var(--pf-t--global--color--nonstatus--blue--default);
     722              : +    color: black;
     723              : +}
     724              : +
     725              : +/* Style the breadcrumb component as a path */
     726              : +.file-chooser-listing-breadcrumbs .pf-v6-c-breadcrumb__item-divider {
     727              : +    > svg {
     728              : +        display: none;
     729              : +    }
     730              : +
     731              : +    &::after {
     732              : +        content: "/";
     733              : +    }
     734              : +}
     735              : +
     736              : +/* Size, align, and space icon correctly */
     737              : +.file-chooser-listing-breadcrumbs .breadcrumb-hdd-icon {
     738              : +    /* Set the size to a large icon */
     739              : +    block-size: var(--pf-t--global--font--size--lg);
     740              : +    /* Width should resolve itself based on height and aspect ratio */
     741              : +    inline-size: auto;
     742              : +    /* Align to the middle (as one would expect) */
     743              : +    vertical-align: middle;
     744              : +}
     745              : diff --git a/pkg/lib/cockpit/react/FileChooser.tsx b/pkg/lib/cockpit/react/FileChooser.tsx
     746              : new file mode 100644
     747              : index 000000000..8931a5e4e
     748              : --- /dev/null
     749              : +++ b/pkg/lib/cockpit/react/FileChooser.tsx
     750              : @@ -0,0 +1,967 @@
     751              : +/*
     752              : + * Copyright (C) 2026 Red Hat, Inc.
     753              : + * SPDX-License-Identifier: LGPL-2.1-or-later
     754              : + */
     755              : +
     756              : +/* This file exports two components
     757              : +
     758              : +   - a FileChooser component that can be used with "Dialogs.show" to
     759              : +     show a configurable, general purpose file chooser dialog
     760              : +
     761              : +   - a DialogFileChooserInput component that can be used with
     762              : +     "useDialogState" etc as a text input field for pathnames in
     763              : +     dialogs.
     764              : +
     765              : +   A FileChooser is configured via these properties:
     766              : +
     767              : +   - title: string
     768              : +
     769              : +   The title in the header of the dialog.
     770              : +
     771              : +   - filters?: undefined | FileChooserFilter[];
     772              : +
     773              : +   A list of "prepared filters".  A filter looks like this:
     774              : +
     775              : +     interface FileChooserFilter {
     776              : +       label: string;
     777              : +       filter: (name: string, type: string) => boolean,
     778              : +     }
     779              : +
     780              : +   The "filter" function will be called with the base name of a file
     781              : +   and its type.  The type is the string returned by "fsinfo", such as
     782              : +   "reg", "dir", "blk", etc.
     783              : +
     784              : +   - shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>)
     785              : +
     786              : +   A list of additional shortcuts to display in the sidebar of the
     787              : +   dialog.  A shortcut looks like this:
     788              : +
     789              : +     interface FileChooserShortcut {
     790              : +       label: string;
     791              : +       path: string;
     792              : +     }
     793              : +
     794              : +   The path should point to a existing directory.
     795              : +
     796              : +   Instead of a array of shortcuts, you can also pass a async function
     797              : +   that will return the array.  The function will be called each time
     798              : +   when the dialog is opened.
     799              : +
     800              : +   - collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
     801              : +
     802              : +   A list of additional collections. A collection is a list of files
     803              : +   that are not necessarily in the same directory.  The "Recent" entry
     804              : +   in the sidebar is a collection, for example.  A collection looks like this:
     805              : +
     806              : +     interface FileChooserCollection {
     807              : +       label: string;
     808              : +       emptyLabel: string;
     809              : +       list: () => Promise<string[]>;
     810              : +     }
     811              : +
     812              : +    The "list" function should return absolute pathnames. The
     813              : +    FileChooser will query their actual types and filter out any entry
     814              : +    that does not actually exist.  The files will not be further
     815              : +    re-ordered before displaying them. If you want them to be sorted,
     816              : +    you need to do that before returning the array.
     817              : +
     818              : +   - onlyDirectories?: undefined | boolean;
     819              : +
     820              : +   If true, show only directories and let the user select a
     821              : +   directory.  If false, directories are of course shown, but they
     822              : +   can't be selected.
     823              : +
     824              : +   - superuser?: cockpit.SuperuserMode;
     825              : +
     826              : +   The "superuser" option to use when listing files, etc.
     827              : +
     828              : +   - recentKey?: undefined | string;
     829              : +
     830              : +   A key for localStorage to retrieve the list of recent files.
     831              : +   Defaults to "recent-files".
     832              : +
     833              : +   - actionLabel?: string;
     834              : +
     835              : +   The label to put into the apply button of the file chooser.
     836              : +   Defaults to "Select".
     837              : +
     838              : +   If you use the FileChooser by itself (and not via
     839              : +   DialogFileChooserInput), you can also specify the following
     840              : +   properties:
     841              : +
     842              : +   - path: string;
     843              : +
     844              : +   The initial path to open at.
     845              : +
     846              : +   - action: (path: string) => Promise<void>
     847              : +
     848              : +   A function to run when the user clicks the apply button.  When this
     849              : +   function throws an exception, the dialog does not close and the
     850              : +   error is shown in the dialog itself.
     851              : +
     852              : +   The DialogFileChooserInput has the same properties as a
     853              : +   DialogTextInput plus this:
     854              : +
     855              : +   - fileChooserProps
     856              : +
     857              : +   The properties to use when opening the FileChooser dialog, such as
     858              : +   "title", "shortcuts", etc.
     859              : +
     860              : + */
     861              : +
     862            2 : +import cockpit from "cockpit";
     863            2 : +import React, { useRef, useCallback, useEffect } from "react";
     864              : +
     865              : +import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal';
     866              : +import { Table, Caption, Tbody, Tr, Td } from '@patternfly/react-table';
     867              : +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js";
     868              : +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js";
     869              : +import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js";
     870              : +import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js';
     871              : +import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js';
     872              : +import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons';
     873              : +import {
     874              : +    TextInputGroup, TextInputGroupMain, TextInputGroupUtilities
     875              : +} from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js';
     876              : +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js';
     877              : +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js';
     878              : +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown";
     879              : +import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
     880              : +import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye";
     881              : +
     882              : +import { KebabDropdown } from "cockpit-components-dropdown";
     883              : +
     884              : +import { useDialogs, WithDialogs } from 'dialogs';
     885              : +import { FsInfoClient, fsinfo } from "cockpit/fsinfo";
     886              : +import { basename, dirname } from "cockpit-path";
     887              : +
     888              : +import {
     889              : +    useDialogState_async,
     890              : +    DialogState,
     891              : +    DialogField,
     892              : +    DialogErrorMessage,
     893              : +    DialogHelperText,
     894              : +    OptionalFormGroup,
     895              : +    DialogActionButton,
     896              : +} from 'cockpit/dialog';
     897              : +
     898              : +import "./FileChooser.css";
     899              : +
     900            2 : +const _ = cockpit.gettext;
     901              : +
     902            1 : +async function getHomeDir(): Promise<string> {
     903            1 : +    if (!cockpit.info.user)
     904            1 : +        await cockpit.init();
     905            1 : +    return cockpit.info.user.home;
     906            1 : +}
     907              : +
     908            1 : +async function getDownloadDir(): Promise<string | null> {
     909            1 : +    try {
     910            1 : +        return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim();
     911            0 : +    } catch (ex) {
     912            0 : +        console.warn("Can't determine downloads directory", String(ex));
     913            0 : +        return null;
     914            0 : +    }
     915            1 : +}
     916              : +
     917            1 : +async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise<FileChooserShortcut[]> {
     918            1 : +    const home = await getHomeDir();
     919            1 : +    const dd = await getDownloadDir();
     920              : +
     921            1 : +    return [
     922            1 : +        { label: _("Home"), path: home },
     923            0 : +        ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []),
     924            1 : +        ...shortcuts,
     925            1 : +    ];
     926            1 : +}
     927              : +
     928            1 : +const FileIcon = () => {
     929            1 : +    return (
     930            1 : +        <svg
     931            1 : +            height="1em"
     932            1 : +            width="1em"
     933            1 : +            xmlns="http://www.w3.org/2000/svg"
     934            1 : +            viewBox="0 0 1536 1792"
     935            1 : +            fill="currentColor"
     936              : +        >
     937            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" />
     938            1 : +        </svg>
     939              : +    );
     940            1 : +};
     941              : +
     942            1 : +function path_join(dir: string, base: string) {
     943            1 : +    return (dir == "/" ? "" : dir) + "/" + base;
     944            1 : +}
     945              : +
     946              : +interface FileInfo {
     947              : +    type: string;
     948              : +    name: string;
     949              : +}
     950              : +
     951            2 : +class FileError {
     952              : +    message: string;
     953              : +
     954            1 : +    constructor(message: string) {
     955            1 : +        this.message = message;
     956            1 : +    }
     957            2 : +}
     958              : +
     959            1 : +function watchFiles(
     960            1 : +    path: string,
     961            1 : +    onlyDirectories: boolean,
     962            1 : +    superuser: cockpit.SuperuserMode,
     963            1 : +    callback: (files: FileError | FileInfo[]) => void,
     964            1 : +): FsInfoClient {
     965            1 : +    const client = new FsInfoClient(
     966            1 : +        path,
     967            1 : +        ["type", "entries", "target", "targets"],
     968            1 : +        {
     969            1 : +            follow: true,
     970            1 : +            ...(superuser ? { superuser } : { })
     971            1 : +        }
     972            1 : +    );
     973              : +
     974            1 : +    client.on("close", message => {
     975            0 : +        if ("message" in message && typeof message.message == "string")
     976            0 : +            callback(new FileError(message.message));
     977            1 : +    });
     978              : +
     979            1 : +    client.on("change", state => {
     980            1 : +        if (state.error) {
     981            1 : +            callback(new FileError(state.error.message));
     982            1 : +            return;
     983            1 : +        }
     984              : +
     985            1 : +        if (!state.info)
     986            1 : +            return;
     987              : +
     988            1 : +        const info = state.info;
     989              : +
     990            0 : +        if (!(info.type && info.entries && info.targets)) {
     991            0 : +            callback(new FileError(_("Permission denied")));
     992            0 : +            return;
     993            0 : +        }
     994              : +
     995            0 : +        if (info.type != "dir") {
     996            0 : +            callback(new FileError(_("Not a directory")));
     997            0 : +            return;
     998            0 : +        }
     999              : +
    1000            1 : +        const result: FileInfo[] = [];
    1001            1 : +        for (const name in info.entries) {
    1002            1 : +            let entry = info.entries[name];
    1003            1 : +            if (entry.type == "lnk" && entry.target)
    1004            1 : +                entry = info.entries[entry.target] || info.targets[entry.target];
    1005              : +
    1006            1 : +            cockpit.assert(entry.type);
    1007            1 : +            if (!onlyDirectories || entry.type == "dir")
    1008            1 : +                result.push({ type: entry.type, name });
    1009            1 : +        }
    1010              : +
    1011            1 : +        result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
    1012            1 : +        callback(result);
    1013            1 : +    });
    1014              : +
    1015            1 : +    return client;
    1016            1 : +}
    1017              : +
    1018            1 : +async function getFileInfos(
    1019            1 : +    paths: string[],
    1020            1 : +    onlyDirectories: boolean,
    1021            1 : +    superuser: cockpit.SuperuserMode,
    1022            1 : +): Promise<FileInfo[]> {
    1023            1 : +    const res: FileInfo[] = [];
    1024              : +
    1025            1 : +    for (const p of paths) {
    1026            1 : +        try {
    1027            1 : +            const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
    1028            1 : +            if (info.type && (!onlyDirectories || info.type == "dir"))
    1029            1 : +                res.push({ name: p, type: info.type });
    1030            1 : +        } catch (ex) {
    1031            1 : +            if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem != "not-found"))
    1032            1 : +                console.error("Failed to get file type:", p);
    1033            1 : +        }
    1034            1 : +    }
    1035              : +
    1036            1 : +    return res;
    1037            1 : +}
    1038              : +
    1039            1 : +async function listRecent(recentKey: string): Promise<string[]> {
    1040            1 : +    const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
    1041            1 : +    if (Array.isArray(recent)) {
    1042            1 : +        return recent.filter(r => typeof r == "string");
    1043            0 : +    } else {
    1044            0 : +        return [];
    1045            0 : +    }
    1046            1 : +}
    1047              : +
    1048            1 : +function boldify(name: string, filterText: string): React.ReactNode {
    1049            1 : +    if (!filterText)
    1050            1 : +        return name;
    1051            1 : +    const parts: React.ReactNode[] = [];
    1052            1 : +    let pos;
    1053            1 : +    while ((pos = name.indexOf(filterText)) >= 0) {
    1054            1 : +        parts.push(name.substring(0, pos));
    1055            1 : +        parts.push(<u key={pos}>{name.substring(pos, pos + filterText.length)}</u>);
    1056            1 : +        name = name.substring(pos + filterText.length);
    1057            1 : +    }
    1058            1 : +    if (name)
    1059            1 : +        parts.push(name);
    1060            1 : +    return parts;
    1061            1 : +}
    1062              : +
    1063              : +export interface FileChooserFilter {
    1064              : +    label: string;
    1065              : +    filter: (name: string, type: string) => boolean,
    1066              : +}
    1067              : +
    1068              : +export interface FileChooserShortcut {
    1069              : +    label: string;
    1070              : +    path: string;
    1071              : +}
    1072              : +
    1073              : +export interface FileChooserCollection {
    1074              : +    label: string;
    1075              : +    emptyLabel: string;
    1076              : +    list: () => Promise<string[]>;
    1077              : +}
    1078              : +
    1079              : +export interface FileChooserProps {
    1080              : +    title: string;
    1081              : +    shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
    1082              : +    filters?: undefined | FileChooserFilter[];
    1083              : +    collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
    1084              : +    onlyDirectories?: undefined | boolean;
    1085              : +    superuser?: cockpit.SuperuserMode;
    1086              : +    recentKey?: undefined | string;
    1087              : +    actionLabel?: string;
    1088              : +}
    1089              : +
    1090              : +interface FileChooserValues {
    1091              : +    path: string;
    1092              : +    collection: null | FileChooserCollection;
    1093              : +    files: null | FileError | FileInfo[];
    1094              : +    selected: null | FileInfo;
    1095              : +    textFilter: string;
    1096              : +    filters: FileChooserFilter[];
    1097              : +    filter: FileChooserFilter;
    1098              : +    recent_collection: FileChooserCollection;
    1099              : +    shortcuts: FileChooserShortcut[];
    1100              : +    collections: FileChooserCollection[];
    1101              : +    showHidden: boolean;
    1102              : +}
    1103              : +
    1104            1 : +export const FileChooser = ({
    1105            1 : +    title,
    1106            1 : +    shortcuts = [],
    1107            1 : +    filters = [],
    1108            1 : +    collections = [],
    1109            1 : +    onlyDirectories = false,
    1110            1 : +    superuser,
    1111            1 : +    recentKey = "recent-files",
    1112            1 : +    actionLabel,
    1113            1 : +    path = "",
    1114            1 : +    action,
    1115            1 : +} : {
    1116              : +    path?: string,
    1117              : +    action: (path: string) => Promise<void>,
    1118            1 : +} & FileChooserProps) => {
    1119            1 : +    const Dialogs = useDialogs();
    1120            1 : +    const textInputRef = useRef<HTMLInputElement>(null);
    1121            1 : +    const fsInfoClientRef = useRef<FsInfoClient | null>(null);
    1122              : +
    1123            1 : +    function focusFilter() {
    1124            1 : +        textInputRef.current?.focus();
    1125            1 : +    }
    1126              : +
    1127            1 : +    useEffect(() => {
    1128            0 : +        textInputRef.current?.focus();
    1129            1 : +    }, []);
    1130              : +
    1131            1 : +    async function init(): Promise<FileChooserValues> {
    1132            1 : +        const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
    1133              : +
    1134            1 : +        const recent_collection = {
    1135            1 : +            label: _("Recent"),
    1136            1 : +            emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
    1137            1 : +            list: () => listRecent(recentKey)
    1138            1 : +        };
    1139              : +
    1140            1 : +        const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
    1141            1 : +        const collections_list = Array.isArray(collections) ? collections : await collections();
    1142              : +
    1143            1 : +        return {
    1144            1 : +            path,
    1145            1 : +            collection: path == "" ? recent_collection : null,
    1146            1 : +            files: null,
    1147            1 : +            selected: null,
    1148            1 : +            textFilter: "",
    1149            1 : +            filters: all_filters,
    1150            1 : +            filter: all_filters[0],
    1151            1 : +            recent_collection,
    1152            1 : +            shortcuts: await stdShortcuts(shortcuts_list),
    1153            1 : +            collections: collections_list,
    1154            1 : +            showHidden: false,
    1155            1 : +        };
    1156            1 : +    }
    1157              : +
    1158            1 : +    const dlg = useDialogState_async(init);
    1159              : +
    1160            1 : +    const setPath = useCallback(
    1161            1 : +        (dlg: DialogState<FileChooserValues>, path: string) => {
    1162            1 : +            dlg.field("path").set(path);
    1163            1 : +            dlg.field("collection").set(null);
    1164            1 : +            dlg.field("selected").set(null);
    1165            1 : +            dlg.field("files").set(null);
    1166              : +
    1167            1 : +            if (fsInfoClientRef.current)
    1168            1 : +                fsInfoClientRef.current.close();
    1169              : +
    1170            1 : +            fsInfoClientRef.current = watchFiles(
    1171            1 : +                path,
    1172            1 : +                onlyDirectories,
    1173            1 : +                superuser,
    1174            1 : +                files => {
    1175            1 : +                    dlg.field("files").set(files);
    1176            1 : +                }
    1177            1 : +            );
    1178            1 : +        },
    1179            1 : +        [onlyDirectories, superuser],
    1180            1 : +    );
    1181              : +
    1182            1 : +    const setCollection = useCallback(
    1183            1 : +        (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
    1184            1 : +            dlg.field("path").set("");
    1185            1 : +            dlg.field("collection").set(collection);
    1186            1 : +            dlg.field("selected").set(null);
    1187            1 : +            dlg.field("files").set(null);
    1188              : +
    1189            1 : +            if (fsInfoClientRef.current)
    1190            1 : +                fsInfoClientRef.current.close();
    1191              : +
    1192            1 : +            fsInfoClientRef.current = null;
    1193            1 : +            dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
    1194            1 : +        },
    1195            1 : +        [onlyDirectories, superuser],
    1196            1 : +    );
    1197              : +
    1198            1 : +    useEffect(() => {
    1199            1 : +        if (dlg instanceof DialogState) {
    1200            1 : +            if (dlg.values.collection)
    1201            1 : +                setCollection(dlg, dlg.values.collection);
    1202              : +            else
    1203            1 : +                setPath(dlg, dlg.values.path);
    1204            1 : +        }
    1205            1 : +    }, [dlg, setPath, setCollection]);
    1206              : +
    1207            1 : +    function full_path(path: string, selected: string) {
    1208            1 : +        if (path == "")
    1209            1 : +            return selected;
    1210              : +        else
    1211            1 : +            return path_join(path, selected);
    1212            1 : +    }
    1213              : +
    1214            1 : +    function selected_path(): string | null {
    1215            1 : +        if (!(dlg instanceof DialogState))
    1216            1 : +            return null;
    1217              : +
    1218            1 : +        const { selected, path } = dlg.values;
    1219              : +
    1220            1 : +        if (onlyDirectories) {
    1221            1 : +            if (!selected && path != "")
    1222            1 : +                return path;
    1223            1 : +            else if (selected && selected.type == "dir")
    1224            1 : +                return full_path(path, selected.name);
    1225            1 : +        } else {
    1226            1 : +            if (selected && selected.type != "dir")
    1227            1 : +                return full_path(path, selected.name);
    1228            1 : +        }
    1229              : +
    1230            1 : +        return null;
    1231            1 : +    }
    1232              : +
    1233            1 : +    async function onAction() {
    1234            1 : +        const full = selected_path();
    1235            1 : +        cockpit.assert(full);
    1236            1 : +        rememberRecent(full, recentKey);
    1237            1 : +        await action(full);
    1238            1 : +    }
    1239              : +
    1240            1 : +    function breadcrumbs(dlg: DialogState<FileChooserValues>) {
    1241            1 : +        const { path } = dlg.values;
    1242              : +
    1243            1 : +        if (path == "") {
    1244              : +            // Collection
    1245            1 : +            return null;
    1246            1 : +        } else {
    1247            1 : +            const dirs = ["/"].concat(path.split("/").filter(d => !!d));
    1248            1 : +            const crumbs: React.ReactNode[] = [];
    1249            1 : +            let full = "/";
    1250            1 : +            dirs.forEach((d, i) => {
    1251            1 : +                if (d != "/")
    1252            1 : +                    full = path_join(full, d);
    1253            1 : +                const path = full;
    1254            1 : +                crumbs.push(
    1255            1 : +                    <BreadcrumbItem
    1256            1 : +                        key={i}
    1257            1 : +                        to="#"
    1258            1 : +                        onClick={
    1259            1 : +                            (event) => {
    1260            1 : +                                setPath(dlg, path);
    1261            1 : +                                event.preventDefault();
    1262            1 : +                            }
    1263              : +                        }
    1264            1 : +                        isActive={i == dirs.length - 1}
    1265              : +                    >
    1266            1 : +                        { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
    1267            1 : +                    </BreadcrumbItem>
    1268            1 : +                );
    1269            1 : +            });
    1270              : +
    1271            1 : +            if (crumbs.length > 0) {
    1272            1 : +                return (
    1273            1 : +                    <Breadcrumb>
    1274            1 : +                        {crumbs}
    1275            1 : +                    </Breadcrumb>
    1276              : +                );
    1277            1 : +            }
    1278            1 : +        }
    1279            1 : +    }
    1280              : +
    1281            1 : +    function header(dlg: DialogState<FileChooserValues>) {
    1282            1 : +        const preparedFilters = (
    1283            1 : +            dlg.values.filters.length > 1 &&
    1284            1 : +                <ToggleGroup>
    1285              : +                    {
    1286            1 : +                        dlg.values.filters.map(f => {
    1287            1 : +                            return (
    1288            1 : +                                <ToggleGroupItem
    1289            1 : +                                    key={f.label}
    1290            1 : +                                    isSelected={f == dlg.values.filter}
    1291            1 : +                                    onChange={() => {
    1292            1 : +                                        dlg.field("filter").set(f);
    1293            1 : +                                        focusFilter();
    1294            1 : +                                    }}
    1295            1 : +                                    text={f.label}
    1296            1 : +                                />
    1297              : +                            );
    1298            1 : +                        })
    1299              : +                    }
    1300            1 : +                </ToggleGroup>
    1301              : +        );
    1302              : +
    1303            1 : +        const textFilter = (
    1304            1 : +            <TextInput
    1305            1 : +                ref={textInputRef}
    1306            1 : +                placeholder={_("Type to filter")}
    1307            1 : +                value={dlg.values.textFilter}
    1308            1 : +                onChange={(_event, value) => dlg.field("textFilter").set(value)}
    1309            1 : +            />
    1310              : +        );
    1311              : +
    1312            1 : +        function shortcut(sc: FileChooserShortcut) {
    1313            1 : +            return (
    1314            1 : +                <DropdownItem
    1315            1 : +                    key={sc.label}
    1316            1 : +                    onClick={() => setPath(dlg, sc.path)}
    1317            1 : +                    className="file-chooser-hide-on-wide"
    1318              : +                >
    1319            1 : +                    {sc.label}
    1320            1 : +                </DropdownItem>
    1321              : +            );
    1322            1 : +        }
    1323              : +
    1324            1 : +        function collection(cl: FileChooserCollection) {
    1325            1 : +            return (
    1326            1 : +                <DropdownItem
    1327            1 : +                    key={cl.label}
    1328            0 : +                    onClick={() => setCollection(dlg, cl)}
    1329            1 : +                    className="file-chooser-hide-on-wide"
    1330              : +                >
    1331            1 : +                    {cl.label}
    1332            1 : +                </DropdownItem>
    1333              : +            );
    1334            1 : +        }
    1335              : +
    1336            1 : +        return (
    1337            1 : +            <Flex>
    1338            1 : +                <FlexItem>
    1339            1 : +                    {textFilter}
    1340            1 : +                </FlexItem>
    1341            1 : +                <FlexItem>
    1342            1 : +                    {preparedFilters}
    1343            1 : +                </FlexItem>
    1344            1 : +                <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
    1345            1 : +                    <KebabDropdown
    1346            1 : +                        dropdownItems={
    1347            1 : +                            [
    1348            1 : +                                <DropdownItem
    1349            1 : +                                    key="jump"
    1350            1 : +                                    onClick={
    1351            0 : +                                        () => {
    1352            0 : +                                            cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
    1353            0 : +                                        }
    1354              : +                                    }
    1355            1 : +                                    isDisabled={dlg.values.path === ""}
    1356              : +                                >
    1357            1 : +                                    {_("Open in file browser")}
    1358            1 : +                                </DropdownItem>,
    1359            1 : +                                <DropdownItem
    1360            1 : +                                    key="showhide"
    1361            1 : +                                    onClick={
    1362            1 : +                                        () => {
    1363            1 : +                                            dlg.field("showHidden").set(!dlg.values.showHidden);
    1364            1 : +                                        }
    1365              : +                                    }
    1366              : +                                >
    1367            1 : +                                    {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
    1368            1 : +                                </DropdownItem>,
    1369            1 : +                                <Divider key="divider" className="file-chooser-hide-on-wide" />,
    1370            1 : +                                collection(dlg.values.recent_collection),
    1371            1 : +                                ...dlg.values.shortcuts.map(shortcut),
    1372            1 : +                                shortcut({ label: _("Filesystem"), path: "/" }),
    1373            1 : +                                ...dlg.values.collections.map(collection)
    1374            1 : +                            ]
    1375              : +                        }
    1376            1 : +                    />
    1377            1 : +                </FlexItem>
    1378            1 : +            </Flex>
    1379              : +        );
    1380            1 : +    }
    1381              : +
    1382            1 : +    function formatIcon(f: FileInfo): React.ReactNode {
    1383            1 : +        if (f.type == "dir")
    1384            1 : +            return <FolderIcon />;
    1385              : +        else
    1386            1 : +            return <FileIcon />;
    1387            1 : +    }
    1388              : +
    1389            1 : +    function sidebar(dlg: DialogState<FileChooserValues>) {
    1390            1 : +        function shortcut(sc: FileChooserShortcut) {
    1391            1 : +            return (
    1392            1 : +                <Tr
    1393            1 : +                    key={sc.label}
    1394            1 : +                    isClickable
    1395            1 : +                    isSelectable
    1396            1 : +                    isRowSelected={dlg.values.path == sc.path}
    1397            1 : +                    onRowClick={
    1398            1 : +                        () => {
    1399            1 : +                            setPath(dlg, sc.path);
    1400            1 : +                            focusFilter();
    1401            1 : +                        }
    1402              : +                    }
    1403              : +                >
    1404            1 : +                    <Td>{sc.label}</Td>
    1405            1 : +                </Tr>
    1406              : +            );
    1407            1 : +        }
    1408              : +
    1409            1 : +        function collection(col: FileChooserCollection) {
    1410            1 : +            return (
    1411            1 : +                <Tr
    1412            1 : +                    key={col.label}
    1413            1 : +                    isClickable
    1414            1 : +                    isSelectable
    1415            1 : +                    isRowSelected={dlg.values.collection == col}
    1416            1 : +                    onRowClick={
    1417            1 : +                        () => {
    1418            1 : +                            setCollection(dlg, col);
    1419            1 : +                            focusFilter();
    1420            1 : +                        }
    1421              : +                    }
    1422              : +                >
    1423            1 : +                    <Td>{col.label}</Td>
    1424            1 : +                </Tr>
    1425              : +            );
    1426            1 : +        }
    1427              : +
    1428            1 : +        return (
    1429            1 : +            <Table variant="compact" borders={false}>
    1430            1 : +                <Tbody>
    1431            1 : +                    { collection(dlg.values.recent_collection) }
    1432            1 : +                    { dlg.values.shortcuts.map(shortcut) }
    1433            1 : +                    { shortcut({ label: _("Filesystem"), path: "/" }) }
    1434            1 : +                    { dlg.values.collections.map(collection) }
    1435            1 : +                </Tbody>
    1436            1 : +            </Table>
    1437              : +        );
    1438            1 : +    }
    1439              : +
    1440            1 : +    function listing(dlg: DialogState<FileChooserValues>) {
    1441            1 : +        function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
    1442            1 : +            return (
    1443            1 : +                <Caption>
    1444            1 : +                    <EmptyState
    1445            1 : +                        titleText={content}
    1446            1 : +                        icon={icon}
    1447              : +                    >
    1448            1 : +                        { (clearFilters > 0) &&
    1449            1 : +                            <EmptyStateActions>
    1450            1 : +                                <Button
    1451            1 : +                                    variant="link"
    1452            1 : +                                    onClick={() => {
    1453            1 : +                                        if (clearFilters == 3) {
    1454            1 : +                                            dlg.field("showHidden").set(true);
    1455            1 : +                                        } else {
    1456            1 : +                                            dlg.field("textFilter").set("");
    1457            1 : +                                            if (clearFilters > 1)
    1458            1 : +                                                dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
    1459            1 : +                                        }
    1460            1 : +                                        focusFilter();
    1461            1 : +                                    }}
    1462              : +                                >
    1463            1 : +                                    {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
    1464            1 : +                                </Button>
    1465            1 : +                            </EmptyStateActions>
    1466              : +                        }
    1467            1 : +                    </EmptyState>
    1468            1 : +                </Caption>
    1469              : +            );
    1470            1 : +        }
    1471              : +
    1472            1 : +        function listingBody() {
    1473            1 : +            const files = dlg.values.files;
    1474              : +
    1475            1 : +            if (files == null)
    1476            1 : +                return emptyState("", Spinner);
    1477              : +
    1478            1 : +            if (files instanceof FileError)
    1479            1 : +                return emptyState(files.message, FolderIcon);
    1480              : +
    1481            1 : +            if (files.length == 0) {
    1482            1 : +                if (dlg.values.collection) {
    1483            1 : +                    return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
    1484            1 : +                } else if (!onlyDirectories) {
    1485            1 : +                    return emptyState(_("Directory is empty"), FolderIcon);
    1486            0 : +                } else {
    1487            0 : +                    return emptyState(_("Directory has no sub-directories"), FolderIcon);
    1488            0 : +                }
    1489            1 : +            }
    1490              : +
    1491            1 : +            const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
    1492            1 : +            if (withoutHidden.length == 0)
    1493            1 : +                return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
    1494              : +
    1495            1 : +            const preFiltered = withoutHidden.filter(
    1496            1 : +                f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
    1497            1 : +            );
    1498            1 : +            if (preFiltered.length == 0)
    1499            1 : +                return emptyState(_("No matching results"), SearchIcon, 2);
    1500              : +
    1501            1 : +            const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
    1502            1 : +            if (filtered.length == 0)
    1503            1 : +                return emptyState(_("No matching results"), SearchIcon, 1);
    1504              : +
    1505            1 : +            return (
    1506            1 : +                <Tbody>
    1507              : +                    {
    1508            1 : +                        filtered.map(
    1509            1 : +                            (f, idx) => {
    1510            1 : +                                let name, location;
    1511            1 : +                                if (dlg.values.path == "") {
    1512            1 : +                                    name = basename(f.name);
    1513            1 : +                                    location = dirname(f.name);
    1514            1 : +                                } else {
    1515            1 : +                                    name = f.name;
    1516            1 : +                                }
    1517            1 : +                                return (
    1518            1 : +                                    <Tr
    1519            1 : +                                        className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
    1520            1 : +                                        key={idx}
    1521            1 : +                                        data-name={name}
    1522            1 : +                                        onRowClick={
    1523            1 : +                                            () => {
    1524            1 : +                                                dlg.field("selected").set(f);
    1525            1 : +                                                focusFilter();
    1526            1 : +                                            }
    1527              : +                                        }
    1528            1 : +                                        onDoubleClick={
    1529            1 : +                                            event => {
    1530            1 : +                                                event.preventDefault();
    1531            1 : +                                                if (f.type == "dir")
    1532            1 : +                                                    setPath(dlg, full_path(dlg.values.path, f.name));
    1533            1 : +                                                dlg.field("textFilter").set("");
    1534            1 : +                                                focusFilter();
    1535            1 : +                                            }
    1536              : +                                        }
    1537            1 : +                                        isClickable
    1538              : +                                    >
    1539            1 : +                                        <Td>
    1540            1 : +                                            {formatIcon(f)}
    1541              : +                                            &nbsp;&nbsp;
    1542            1 : +                                            {boldify(name, dlg.values.textFilter)}
    1543            1 : +                                        </Td>
    1544            1 : +                                        { location && <Td>{location}</Td> }
    1545            1 : +                                    </Tr>
    1546              : +                                );
    1547            1 : +                            }
    1548            1 : +                        )
    1549              : +                    }
    1550            1 : +                </Tbody>
    1551              : +            );
    1552            1 : +        }
    1553              : +
    1554            1 : +        return (
    1555            1 : +            <Table variant="compact" borders={false}>
    1556            1 : +                { listingBody() }
    1557            1 : +            </Table>
    1558              : +        );
    1559            1 : +    }
    1560              : +
    1561            1 : +    return (
    1562            1 : +        <Modal
    1563            1 : +            isOpen
    1564            1 : +            variant="large"
    1565            1 : +            position="top"
    1566            1 : +            onClose={Dialogs.close}
    1567            1 : +            className="file-chooser"
    1568              : +        >
    1569            1 : +            <ModalHeader
    1570            1 : +                title={title}
    1571            1 : +                description={<DialogErrorMessage dialog={dlg} />}
    1572            1 : +            />
    1573            1 : +            <ModalBody>
    1574            1 : +                <div className="file-chooser-body">
    1575            1 : +                    <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
    1576              : +                        {
    1577            1 : +                            dlg instanceof DialogState
    1578            1 : +                                ? sidebar(dlg)
    1579            1 : +                                : <Bullseye><Spinner /></Bullseye>
    1580              : +                        }
    1581            1 : +                    </div>
    1582            1 : +                    <div className="file-chooser-listing-header">
    1583            1 : +                        { dlg instanceof DialogState && header(dlg) }
    1584            1 : +                    </div>
    1585            1 : +                    <div className="file-chooser-listing-breadcrumbs">
    1586            1 : +                        { dlg instanceof DialogState && breadcrumbs(dlg) }
    1587            1 : +                    </div>
    1588            1 : +                    <div className="file-chooser-listing-body">
    1589            1 : +                        { dlg instanceof DialogState && listing(dlg) }
    1590            1 : +                    </div>
    1591            1 : +                </div>
    1592            1 : +            </ModalBody>
    1593            1 : +            <ModalFooter>
    1594            1 : +                <DialogActionButton
    1595            1 : +                    dialog={dlg}
    1596            1 : +                    isAriaDisabled={selected_path() === null}
    1597            1 : +                    action={onAction}
    1598            1 : +                    onClose={Dialogs.close}
    1599              : +                >
    1600            1 : +                    {actionLabel || _("Select")}
    1601            1 : +                </DialogActionButton>
    1602            1 : +            </ModalFooter>
    1603            1 : +        </Modal>
    1604              : +    );
    1605            1 : +};
    1606              : +
    1607            2 : +const FileChooserButton = ({
    1608            2 : +    value,
    1609            2 : +    onChoose,
    1610            2 : +    props,
    1611            2 : +} : {
    1612              : +    value: string,
    1613              : +    onChoose: (path: string) => void,
    1614              : +    props: FileChooserProps,
    1615            2 : +}) => {
    1616            2 : +    const Dialogs = useDialogs();
    1617              : +
    1618            2 : +    return (
    1619            2 : +        <Button
    1620            2 : +            variant="plain"
    1621            2 : +            icon={<FolderOpenIcon />}
    1622            2 : +            onClick={
    1623            1 : +                async () => {
    1624            1 : +                    Dialogs.show(
    1625            1 : +                        <FileChooser
    1626            1 : +                            path={value[0] == "/" ? dirname(value) : ""}
    1627            1 : +                            action={async path => onChoose(path)}
    1628            1 : +                            {...props}
    1629            1 : +                        />
    1630            1 : +                    );
    1631            1 : +                }
    1632              : +            }
    1633            2 : +        />
    1634              : +    );
    1635            2 : +};
    1636              : +
    1637            2 : +export const FileChooserInput = ({
    1638            2 : +    ouiaId,
    1639            2 : +    placeholder = "",
    1640            2 : +    value,
    1641            2 : +    onChange,
    1642            2 : +    isDisabled = false,
    1643            2 : +    fileChooserProps,
    1644            2 : +} : {
    1645              : +    ouiaId?: undefined | string;
    1646              : +    placeholder?: string,
    1647              : +    value: string,
    1648              : +    onChange: (path: string, from_dialog: boolean) => void,
    1649              : +    isDisabled?: boolean,
    1650              : +    fileChooserProps: FileChooserProps,
    1651            2 : +}) => {
    1652            2 : +    return (
    1653            2 : +        <TextInputGroup
    1654            2 : +            isDisabled={isDisabled}
    1655            2 : +            data-ouia-component-id={ouiaId}
    1656              : +        >
    1657            2 : +            <TextInputGroupMain
    1658            2 : +                value={value}
    1659            2 : +                placeholder={placeholder}
    1660            1 : +                onChange={(_event, value) => onChange(value, false)}
    1661            2 : +                autoComplete="off"
    1662            2 : +            />
    1663            2 : +            <TextInputGroupUtilities>
    1664            2 : +                <WithDialogs>
    1665            2 : +                    <FileChooserButton
    1666            2 : +                        value={value}
    1667            1 : +                        onChoose={value => onChange(value, true)}
    1668            2 : +                        props={fileChooserProps}
    1669            2 : +                    />
    1670            2 : +                </WithDialogs>
    1671            2 : +            </TextInputGroupUtilities>
    1672            2 : +        </TextInputGroup>
    1673              : +    );
    1674            2 : +};
    1675              : +
    1676            2 : +export const DialogFileChooserInput = ({
    1677            2 : +    field,
    1678            2 : +    label,
    1679            2 : +    placeholder = "",
    1680            2 : +    explanation,
    1681            2 : +    warning,
    1682            2 : +    excuse,
    1683            2 : +    fileChooserProps,
    1684            2 : +} : {
    1685              : +    field: DialogField<string>,
    1686              : +    label: string,
    1687              : +    placeholder?: string,
    1688              : +    explanation?: React.ReactNode,
    1689              : +    warning?: React.ReactNode,
    1690              : +    excuse?: string | null | undefined | false,
    1691              : +    fileChooserProps: FileChooserProps,
    1692            2 : +}) => {
    1693            2 : +    return (
    1694            2 : +        <OptionalFormGroup
    1695            2 : +            label={label}
    1696              : +        >
    1697            2 : +            <FileChooserInput
    1698            2 : +                ouiaId={field.ouia_id()}
    1699            2 : +                placeholder={placeholder}
    1700            2 : +                value={field.get()}
    1701            1 : +                onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
    1702            2 : +                isDisabled={!!excuse}
    1703            2 : +                fileChooserProps={fileChooserProps}
    1704            2 : +            />
    1705            2 : +            <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
    1706            2 : +        </OptionalFormGroup>
    1707              : +    );
    1708            2 : +};
    1709              : +
    1710            1 : +export function rememberRecent(name: string, recentKey: string = "recent-files") {
    1711            1 : +    const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
    1712            1 : +    if (Array.isArray(value)) {
    1713            1 : +        const recent = value.filter(r => typeof r == "string" && r != name);
    1714            1 : +        recent.unshift(name);
    1715            1 : +        window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
    1716            1 : +    }
    1717            1 : +}
    1718              : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
    1719              : index 911148f73..4a4e0a36f 100644
    1720              : --- a/pkg/playground/dialog.tsx
    1721              : +++ b/pkg/playground/dialog.tsx
    1722              : @@ -36,6 +36,8 @@ import {
    1723              :      DialogActionButton, DialogCancelButton,
    1724              :  } from 'cockpit/dialog';
    1725              :  
    1726              : +import { FileChooser, DialogFileChooserInput } from "cockpit/react/FileChooser";
    1727              : +
    1728              :  import 'cockpit-dark-theme'; // once per page
    1729              :  import 'page.scss';
    1730              :  
    1731              : @@ -107,11 +109,11 @@ const NameInput = ({
    1732              :  } : {
    1733              :      field: DialogField<Name>,
    1734              :  }) => {
    1735              : -    return <DialogTextInput field={field.sub("name")} />;
    1736            1 : +    return <DialogTextInput field={field.sub("name")} debounce={1000} />;
    1737              :  };
    1738              :  
    1739              :  function validate_Name(field: DialogField<Name>, countAsyncValidation: () => void) {
    1740              : -    field.sub("name").validate_async(1000, async (n, signal) => {
    1741            1 : +    field.sub("name").validate_async(async (n, signal) => {
    1742              :          await async_sleep(2000);
    1743              :          countAsyncValidation();
    1744              :          if (!signal.aborted)
    1745              : @@ -218,6 +220,9 @@ interface ExampleValues {
    1746              :      alternative: false | string;
    1747              :      error: string;
    1748              :      allow_force: boolean;
    1749              : +    file: string;
    1750              : +    file_explanation: string;
    1751              : +    dir: string;
    1752              :  }
    1753              :  
    1754              :  const ExampleDialog = ({
    1755              : @@ -246,6 +251,9 @@ const ExampleDialog = ({
    1756              :          alternative: false,
    1757              :          error: "none",
    1758              :          allow_force: false,
    1759            2 : +        file: "",
    1760            2 : +        file_explanation: "",
    1761            2 : +        dir: "",
    1762              :      };
    1763              :  
    1764              :      function validate(dlg: DialogState<ExampleValues>) {
    1765              : @@ -256,7 +264,7 @@ const ExampleDialog = ({
    1766              :              });
    1767              :          }
    1768              :          if (dlg.values.dropdown == "three") {
    1769              : -            dlg.field("text3").validate_async(1000, async v => {
    1770            1 : +            dlg.field("text3").validate(v => {
    1771              :                  if (!v)
    1772              :                      return "Can't be empty";
    1773              :              });
    1774              : @@ -270,6 +278,10 @@ const ExampleDialog = ({
    1775              :              });
    1776              :          });
    1777              :          dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
    1778            1 : +        dlg.field("file").validate(v => {
    1779            0 : +            if (v && v[0] != "/")
    1780            0 : +                return "Must be absolute";
    1781            1 : +        });
    1782              :      }
    1783              :  
    1784              :      const dlg = useDialogState(init, validate);
    1785              : @@ -299,8 +311,8 @@ const ExampleDialog = ({
    1786              :          }
    1787              :      }
    1788              :  
    1789              : -    function update_color() {
    1790              : -        dlg.field("color").get_async(0, async (val, signal) => {
    1791            1 : +    function color_changed() {
    1792            1 : +        dlg.field("color").get_async(async (val, signal) => {
    1793              :              signal.onabort = countAsyncCancel;
    1794              :              await async_sleep(2000);
    1795              :              if (!signal.aborted) {
    1796              : @@ -310,13 +322,22 @@ const ExampleDialog = ({
    1797              :          });
    1798              :      }
    1799              :  
    1800              : -    function update_dropdown(val: string) {
    1801              : -        dlg.field("text2").set_async(0, async () => {
    1802            1 : +    function dropdown_changed(val: string) {
    1803            1 : +        dlg.field("text2").set_async(async () => {
    1804              :              await async_sleep(2000);
    1805              :              return val;
    1806              :          });
    1807              :      }
    1808              :  
    1809            1 : +    function file_changed(val: string) {
    1810            1 : +        dlg.field("file_explanation").set_async(async () => {
    1811            1 : +            if (val[0] == "/")
    1812            1 : +                return cockpit.spawn(["file", "-b", val], { superuser: "try" });
    1813              : +            else
    1814            1 : +                return "--";
    1815            1 : +        });
    1816            1 : +    }
    1817              : +
    1818              :      return (
    1819              :          <Modal
    1820              :              id="dialog"
    1821              : @@ -337,6 +358,7 @@ const ExampleDialog = ({
    1822              :                      <DialogTextInput
    1823              :                          label="Text"
    1824              :                          field={dlg.field("text")}
    1825            2 : +                        debounce={0}
    1826              :                          excuse={!dlg.values.flag && "Disabled"}
    1827              :                          explanation="Explanation"
    1828              :                          warning={dlg.values.text == "warn" ? "Warning" : null}
    1829              : @@ -374,7 +396,7 @@ const ExampleDialog = ({
    1830              :                      />
    1831              :                      <DialogDropdownSelect
    1832              :                          label="Dropdown"
    1833              : -                        field={dlg.field("dropdown", update_dropdown)}
    1834            2 : +                        field={dlg.field("dropdown", dropdown_changed)}
    1835              :                          options={
    1836              :                              [
    1837              :                                  { value: "one", label: "Eins" },
    1838              : @@ -386,11 +408,11 @@ const ExampleDialog = ({
    1839              :                      />
    1840              :                      {
    1841              :                          dlg.values.dropdown == "three" &&
    1842              : -                            <DialogTextInput label="Text3" field={dlg.field("text3")} />
    1843            1 : +                            <DialogTextInput label="Text3" field={dlg.field("text3")} debounce={1000} />
    1844              :                      }
    1845              :                      <DialogDropdownSelectObject
    1846              :                          label="DropdownObject"
    1847              : -                        field={dlg.field("color", update_color)}
    1848            2 : +                        field={dlg.field("color").notify(color_changed)}
    1849              :                          options={colors}
    1850              :                          option_label={c => c.name}
    1851              :                      />
    1852              : @@ -412,6 +434,49 @@ const ExampleDialog = ({
    1853              :                          checkbox_label="Allow force"
    1854              :                          field={dlg.field("allow_force")}
    1855              :                      />
    1856            2 : +                    <DialogFileChooserInput
    1857            2 : +                        label="File"
    1858            2 : +                        field={dlg.field("file", file_changed)}
    1859            2 : +                        explanation={dlg.values.file_explanation}
    1860            2 : +                        fileChooserProps={
    1861            2 : +                            {
    1862            2 : +                                title: "Select a file",
    1863            2 : +                                superuser: "try",
    1864            2 : +                                filters: [
    1865            1 : +                                    { label: "No dots", filter: n => !n.includes(".") },
    1866            2 : +                                ],
    1867            2 : +                                shortcuts: [
    1868            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1869            2 : +                                ],
    1870            2 : +                                collections: [
    1871            2 : +                                    {
    1872            2 : +                                        label: "Some files",
    1873            2 : +                                        emptyLabel: "Nothing there",
    1874            1 : +                                        list: async () => {
    1875            1 : +                                            return [
    1876            1 : +                                                "/var/lib/cockpittest/file-chooser-test/dots.txt",
    1877            1 : +                                                "/var/lib/cockpittest/file-chooser-test/foo",
    1878            1 : +                                            ];
    1879            1 : +                                        }
    1880            2 : +                                    }
    1881            2 : +                                ]
    1882            2 : +                            }
    1883              : +                        }
    1884            2 : +                    />
    1885            2 : +                    <DialogFileChooserInput
    1886            2 : +                        label="Directory"
    1887            2 : +                        field={dlg.field("dir")}
    1888            2 : +                        fileChooserProps={
    1889            2 : +                            {
    1890            2 : +                                title: "Select a directory",
    1891            2 : +                                onlyDirectories: true,
    1892            2 : +                                superuser: "try",
    1893            2 : +                                shortcuts: [
    1894            2 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1895            2 : +                                ],
    1896            2 : +                            }
    1897              : +                        }
    1898            2 : +                    />
    1899              :                  </Form>
    1900              :              </ModalBody>
    1901              :              <ModalFooter>
    1902              : @@ -591,7 +656,7 @@ const AsyncExampleDialog = ({
    1903              :      }
    1904              :  
    1905              :      function validate(dlg: DialogState<AsyncExampleValues>) {
    1906              : -        dlg.field("text").validate_async(0, async () => {
    1907            1 : +        dlg.field("text").validate_async(async () => {
    1908              :              throw Error("upps");
    1909              :          });
    1910              :      }
    1911              : @@ -607,7 +672,7 @@ const AsyncExampleDialog = ({
    1912              :          Dialogs.close();
    1913              :      }
    1914              :  
    1915              : -    function update_top(values: AsyncExampleValues) {
    1916            1 : +    function top_changed(values: AsyncExampleValues) {
    1917              :          console.log("TOP", JSON.stringify(values));
    1918              :      }
    1919              :  
    1920              : @@ -621,10 +686,10 @@ const AsyncExampleDialog = ({
    1921              :      } else if (dlg instanceof DialogError) {
    1922              :          body = null;
    1923              :      } else if (dlg instanceof DialogState) {
    1924              : -        const vals = dlg.top(update_top);
    1925            1 : +        const fields = dlg.top(top_changed);
    1926              :          body = (
    1927              :              <Form isHorizontal>
    1928              : -                <DialogTextInput label="Text" field={vals.sub("text")} />
    1929            1 : +                <DialogTextInput label="Text" field={fields.sub("text")} />
    1930              :              </Form>
    1931              :          );
    1932              :      }
    1933              : @@ -694,6 +759,65 @@ const SimpleExampleButtons = () => {
    1934              :      );
    1935              :  };
    1936              :  
    1937            2 : +const FileChooserButton = () => {
    1938            2 : +    const Dialogs = useDialogs();
    1939              : +
    1940            1 : +    async function loadFile(path: string) {
    1941            1 : +        const data = await cockpit.file(path).read();
    1942            1 : +        if (!data.startsWith("foo"))
    1943            1 : +            throw new Error("Does not start with \"foo\"");
    1944            1 : +    }
    1945              : +
    1946            2 : +    return (
    1947            2 : +        <Button
    1948            2 : +            id="open-file-chooser"
    1949            2 : +            onClick={
    1950            1 : +                () => Dialogs.show(
    1951            1 : +                    <FileChooser
    1952            1 : +                        title={"Select a file that starts with \"foo\""}
    1953            1 : +                        actionLabel="Load"
    1954            1 : +                        action={loadFile}
    1955            1 : +                        filters={
    1956            1 : +                            [
    1957            1 : +                                {
    1958            1 : +                                    label: "TXT files",
    1959            1 : +                                    filter: (name, type) => type == "reg" && !!name.match("\\.txt$")
    1960            1 : +                                },
    1961            1 : +                            ]
    1962              : +                        }
    1963            1 : +                        shortcuts={
    1964            1 : +                            async () => {
    1965            1 : +                                async_sleep(500);
    1966            1 : +                                return [
    1967            1 : +                                    { label: "Test files", path: "/var/lib/cockpittest" }
    1968            1 : +                                ];
    1969            1 : +                            }
    1970              : +                        }
    1971            1 : +                        collections={
    1972            1 : +                            async () => {
    1973            1 : +                                return [
    1974            1 : +                                    {
    1975            1 : +                                        label: "Some TXT files",
    1976            1 : +                                        emptyLabel: "Nothing there",
    1977            1 : +                                        list: async () => {
    1978            1 : +                                            return [
    1979            1 : +                                                "/var/lib/cockpittest/file-chooser-test/text/foo.txt",
    1980            1 : +                                                "/var/lib/cockpittest/file-chooser-test/no-such-file.txt"
    1981            1 : +                                            ];
    1982            1 : +                                        }
    1983            1 : +                                    }
    1984            1 : +                                ];
    1985            1 : +                            }
    1986              : +                        }
    1987            1 : +                    />
    1988            1 : +                )
    1989              : +            }
    1990            2 : +        >
    1991              : +            Open FileChooser
    1992            2 : +        </Button>
    1993              : +    );
    1994            2 : +};
    1995              : +
    1996              :  const Demo = () => {
    1997              :      return (
    1998              :          <WithDialogs>
    1999              : @@ -701,6 +825,7 @@ const Demo = () => {
    2000              :                  <PageSection>
    2001              :                      <ExampleButton />
    2002              :                      <SimpleExampleButtons />
    2003            2 : +                    <FileChooserButton />
    2004              :                  </PageSection>
    2005              :              </Page>
    2006              :          </WithDialogs>
    2007              : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
    2008              : index d9f040327..b2f5981c7 100644
    2009              : --- a/test/common/dialoglib.py
    2010              : +++ b/test/common/dialoglib.py
    2011              : @@ -148,3 +148,14 @@ class DialogHelpers:
    2012              :  
    2013              :      def set_DropdownSelect(self, path: str, val: str) -> None:
    2014              :          self.browser.select_from_dropdown(self.field(path), val)
    2015              : +
    2016              : +    # FileChooserInput
    2017              : +
    2018              : +    def get_FileChooserInput(self, path: str) -> str:
    2019              : +        return self.browser.val(self.field(path) + " input")
    2020              : +
    2021              : +    def wait_FileChooserInput(self, path: str, val: str):
    2022              : +        self.browser.wait_val(self.field(path) + " input", val)
    2023              : +
    2024              : +    def set_FileChooserInput(self, path: str, val: str) -> None:
    2025              : +        self.browser.set_input_text(self.field(path) + " input", val)
    2026              : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
    2027              : index 9859000c3..34046f30c 100755
    2028              : --- a/test/verify/check-dialog
    2029              : +++ b/test/verify/check-dialog
    2030              : @@ -183,50 +183,41 @@ class TestDialog(testlib.MachineCase):
    2031              :  
    2032              :          # Debounced and asynchronous validation
    2033              :  
    2034              : -        def enter_online_validation_mode():
    2035              : -            # put dialog into online validation mode by triggering a
    2036              : -            # failed validation
    2037              : -            d.set_Checkbox("flag", val=True)
    2038              : -            d.set_TextInput("text", "")
    2039              : -            b.click(d.apply_button())
    2040              : -            b.wait_in_text(d.helper_text("text"), "Text can not be empty")
    2041              : -            d.set_Checkbox("flag", val=False)
    2042              : -
    2043              :          b.click("#open")
    2044              :          enter_online_validation_mode()
    2045              : +        # Add a field. This immediately starts a validation. (1 validation)
    2046              :          b.click(d.id("async", "add"))
    2047              :          b.focus(d.field("async.0.name"))
    2048              :          b.input_text("1")
    2049              : -        # wait for debounce timeout and validation promise to be done (1 validation)
    2050              : +        # wait for debounce timeout and validation promise to be done (2 validations)
    2051              :          b.wait_in_text(d.helper_text("async.0.name"), "Must have even number")
    2052              :          b.input_text("2")
    2053              : -        # this starts a new timeout and immediately removes the validation error
    2054              : -        b.wait_not_present(d.helper_text("async.0.name"))
    2055              : -        # don't wait for the timeout to be over but change again while it is still pending
    2056              : +        # this starts a new debounce timeout but the validation error stays
    2057              : +        # don't wait for the debounce timeout to be over but change again while it is still pending
    2058              :          time.sleep(0.5)
    2059              :          b.input_text("3")
    2060              :          # now wait for the timeout to be over but change while the promise is running
    2061              :          time.sleep(2)
    2062              :          b.input_text("4")
    2063              : -        # the promise for validating "123" will finish soon, but it's result must be ignored (2 validations)
    2064              : -        # let the validation for "1234" play out to completion (3 validations)
    2065              : +        # the promise for validating "123" will finish soon, but it's result must be ignored (3 validations)
    2066              : +        # let the validation for "1234" play out to completion (4 validations)
    2067              :          time.sleep(5)
    2068              :          # close dialog
    2069              :          b.click(d.apply_button())
    2070              :          b.wait_not_present("#dialog")
    2071              :  
    2072              :          b.wait_text("#async", "1234:4")
    2073              : -        b.wait_text("#asyncVals", "3")
    2074              : +        b.wait_text("#asyncVals", "4")
    2075              :  
    2076              :          b.click("#open")
    2077              :          enter_online_validation_mode()
    2078              : -        b.click(d.id("async", "add"))
    2079              : -        b.click(d.id("async", "add"))
    2080              : +        b.click(d.id("async", "add"))  # (1 validation)
    2081              : +        b.click(d.id("async", "add"))  # (2 validations)
    2082              :          b.focus(d.field("async.1.name"))
    2083              :          b.input_text("1")
    2084              :          # remove the first entry during the debounce timeout, this
    2085              :          # should not disturb anything and the validation for "1"
    2086              : -        # should finish normally (1 validation)
    2087              : +        # should finish normally (3 validations)
    2088              :          b.wait_not_present(d.helper_text("async.0.name"))
    2089              :          b.click(d.id("async.0", "remove"))
    2090              :          b.wait_in_text(d.helper_text("async.0.name"), "Must have even number")
    2091              : @@ -236,7 +227,7 @@ class TestDialog(testlib.MachineCase):
    2092              :          b.wait_not_present("#dialog")
    2093              :  
    2094              :          b.wait_text("#async", "")
    2095              : -        b.wait_text("#asyncVals", "1")
    2096              : +        b.wait_text("#asyncVals", "3")
    2097              :  
    2098              :          # Trigger validation directly via Apply. This will skip the
    2099              :          # timeouts.
    2100              : @@ -251,12 +242,13 @@ class TestDialog(testlib.MachineCase):
    2101              :          b.wait_text("#asyncVals", "1")
    2102              :  
    2103              :          # Trigger validation via Apply when there are timeouts
    2104              : -        # pending. This will cancel the timeouts.
    2105              : +        # pending. This will skip the timeouts.
    2106              :  
    2107              :          b.click("#open")
    2108              :          enter_online_validation_mode()
    2109              :          b.click(d.id("async", "add"))
    2110              : -        # now there is a timeout pending, clicking apply will cancel it.
    2111              : +        # now there is a timeout pending, clicking apply will run it
    2112              : +        # immediately and it will be counted.
    2113              :          b.click(d.apply_button())
    2114              :          b.wait_not_present("#dialog")
    2115              :  
    2116              : @@ -357,6 +349,282 @@ class TestDialog(testlib.MachineCase):
    2117              :          b.click(d.cancel_button())
    2118              :          b.wait_not_present("#dialog")
    2119              :  
    2120              : +    def testFileChooser(self):
    2121              : +        b = self.browser
    2122              : +        m = self.machine
    2123              : +        d = dialoglib.DialogHelpers(b, "#dialog")
    2124              : +        df = dialoglib.DialogHelpers(b, ".file-chooser")
    2125              : +
    2126              : +        # Inject a mock xdg-user-dir utility.
    2127              : +
    2128              : +        self.write_file("/usr/local/bin/xdg-user-dir",
    2129              : +"""#! /bin/sh
    2130              : +echo $HOME/Downloads
    2131              : +""", perm="a+x")
    2132              : +
    2133              : +        # Where our test files are. This is intended to be the same as
    2134              : +        # self.vm_tmpdir, but it is also hard-coded into
    2135              : +        # pkg/playground/dialog.tsx and so we hard-code it here as
    2136              : +        # well.
    2137              : +
    2138              : +        test_files = "/var/lib/cockpittest"
    2139              : +
    2140              : +        self.login_and_go("/playground/dialog", superuser=False)
    2141              : +
    2142              : +        b.click("#open")
    2143              : +
    2144              : +        # Use the get_FileChooserInput method so that Vulture doesn't
    2145              : +        # complain about it being unused.
    2146              : +
    2147              : +        self.assertEqual(d.get_FileChooserInput("file"), "")
    2148              : +
    2149              : +        # The first open has a empty Recent tab.
    2150              : +
    2151              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2152              : +        b.wait_in_text(".file-chooser-listing-body", "No recent files")
    2153              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2154              : +        b.wait_not_present(".file-chooser")
    2155              : +
    2156              : +        # Basic interaction with the text input
    2157              : +
    2158              : +        d.set_FileChooserInput("file", "/home/non-existent/foo")
    2159              : +        b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
    2160              : +
    2161              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2162              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    2163              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2164              : +        b.wait_not_present(".file-chooser")
    2165              : +
    2166              : +        m.upload(["verify/files/file-chooser-test/"], test_files)
    2167              : +        m.execute(f"mkdir '{test_files}/file-chooser-test/empty'")
    2168              : +        d.set_FileChooserInput("file", test_files)
    2169              : +        b.wait_in_text(d.helper_text("file"), "directory")
    2170              : +
    2171              : +        def file(name):
    2172              : +            return f".file-chooser-listing-body tr[data-name='{name}']"
    2173              : +
    2174              : +        # Navigate to empty directory
    2175              : +
    2176              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2177              : +        b.wait_visible(".file-chooser")
    2178              : +        b.mouse(file("cockpittest"), "dblclick")
    2179              : +        b.mouse(file("file-chooser-test"), "dblclick")
    2180              : +        b.mouse(file("empty"), "dblclick")
    2181              : +        b.wait_in_text(".file-chooser-listing-body", "Directory is empty")
    2182              : +
    2183              : +        # Go up and choose tmpdir/file-chooser-test/foo
    2184              : +
    2185              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
    2186              : +        b.assert_pixels(".file-chooser", "basic")
    2187              : +        b.mouse(file("foo"), "click")
    2188              : +        b.click(df.apply_button())
    2189              : +
    2190              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    2191              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    2192              : +
    2193              : +        # "foo" should now be in "Recent"
    2194              : +
    2195              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2196              : +        b.wait_visible(".file-chooser-listing-breadcrumbs nav")
    2197              : +        b.wait_visible(file("foo"))
    2198              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    2199              : +        b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
    2200              : +        b.wait_visible(file("foo"))
    2201              : +        b.wait_in_text(file("foo"), test_files + "/file-chooser-test")
    2202              : +        b.mouse(file("foo"), "click")
    2203              : +        b.click(df.apply_button())
    2204              : +        d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
    2205              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    2206              : +
    2207              : +        # Check that "Home" has some expected files
    2208              : +
    2209              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2210              : +        b.click(".file-chooser-sidebar tr:contains('Home')")
    2211              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadmin")
    2212              : +        b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
    2213              : +        b.mouse(file("admin"), "dblclick")
    2214              : +        b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files")
    2215              : +        b.click(".file-chooser-listing-body button:contains('Show hidden files')")
    2216              : +        b.mouse(file(".ssh"), "dblclick")
    2217              : +        b.mouse(file("authorized_keys"), "click")
    2218              : +        b.click(df.apply_button())
    2219              : +
    2220              : +        d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
    2221              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    2222              : +
    2223              : +        # Check the "Downloads" shortcut
    2224              : +
    2225              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2226              : +        b.click(".file-chooser-sidebar tr:contains('Downloads')")
    2227              : +        b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads")
    2228              : +        b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
    2229              : +
    2230              : +        # Check that we can't read /root
    2231              : +
    2232              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    2233              : +        b.mouse(file("root"), "dblclick")
    2234              : +        b.wait_in_text(".file-chooser-listing-body", "Permission denied")
    2235              : +        b.assert_pixels(".file-chooser", "denied")
    2236              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2237              : +        b.wait_not_present(".file-chooser")
    2238              : +
    2239              : +        # Free text filtering
    2240              : +
    2241              : +        d.set_FileChooserInput("file", "")
    2242              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2243              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    2244              : +        b.mouse(file("file-chooser-test"), "dblclick")
    2245              : +
    2246              : +        b.wait_visible(file("bar"))
    2247              : +        b.wait_visible(file("foo"))
    2248              : +        b.wait_visible(file("foobar"))
    2249              : +
    2250              : +        b.set_input_text(".file-chooser-listing-header input", "fo")
    2251              : +        b.wait_visible(file("foo"))
    2252              : +        b.wait_not_present(file("bar"))
    2253              : +        b.wait_visible(file("foobar"))
    2254              : +        b.assert_pixels(".file-chooser", "filtered")
    2255              : +
    2256              : +        b.set_input_text(".file-chooser-listing-header input", "ba")
    2257              : +        b.wait_not_present(file("foo"))
    2258              : +        b.wait_visible(file("bar"))
    2259              : +        b.wait_visible(file("foobar"))
    2260              : +
    2261              : +        b.set_input_text(".file-chooser-listing-header input", "xxx")
    2262              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    2263              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    2264              : +
    2265              : +        b.wait_visible(file("bar"))
    2266              : +        b.wait_visible(file("foo"))
    2267              : +        b.wait_visible(file("foobar"))
    2268              : +
    2269              : +        # Prepared filtering.
    2270              : +
    2271              : +        # "No dots" was already active all the time, switch it off to
    2272              : +        # reveal more files.
    2273              : +
    2274              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    2275              : +
    2276              : +        b.wait_visible(file("bar"))
    2277              : +        b.wait_visible(file("foo"))
    2278              : +        b.wait_visible(file("foobar"))
    2279              : +        b.wait_visible(file("dots.txt"))
    2280              : +        b.wait_visible(file("only.dots"))
    2281              : +
    2282              : +        b.mouse(file("only.dots"), "dblclick")
    2283              : +        b.wait_visible(file("one.dot"))
    2284              : +        b.wait_visible(file("two.dots"))
    2285              : +
    2286              : +        b.click(".file-chooser-listing-header button:contains('No dots')")
    2287              : +
    2288              : +        b.wait_in_text(".file-chooser-listing-body", "No matching results")
    2289              : +
    2290              : +        # Filter even more, this should get cleared as well
    2291              : +        b.set_input_text(".file-chooser-listing-header input", "x")
    2292              : +
    2293              : +        b.click(".file-chooser-listing-body button:contains('Clear filters')")
    2294              : +
    2295              : +        b.wait_visible(file("one.dot"))
    2296              : +        b.wait_visible(file("two.dots"))
    2297              : +
    2298              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2299              : +        b.wait_not_present(".file-chooser")
    2300              : +
    2301              : +        # Test the collection
    2302              : +
    2303              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2304              : +        b.click(".file-chooser-sidebar tr:contains('Some files')")
    2305              : +        b.wait_visible(file("foo"))
    2306              : +        b.wait_not_present(file("dots.txt"))
    2307              : +        b.click(".file-chooser-listing-header button:contains('All files')")
    2308              : +        b.wait_visible(file("foo"))
    2309              : +        b.wait_visible(file("dots.txt"))
    2310              : +        b.click(file("dots.txt"))
    2311              : +        b.click(df.apply_button())
    2312              : +
    2313              : +        d.wait_FileChooserInput("file", "/var/lib/cockpittest/file-chooser-test/dots.txt")
    2314              : +        b.wait_in_text(d.helper_text("file"), "ASCII text")
    2315              : +
    2316              : +        # Become superuser and access /root/.ssh
    2317              : +
    2318              : +        b.become_superuser()
    2319              : +
    2320              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2321              : +        b.click(".file-chooser-sidebar tr:contains('Filesystem')")
    2322              : +        b.select_PF(".file-chooser-kebab", "Show hidden files")
    2323              : +        b.mouse(file("root"), "dblclick")
    2324              : +        b.mouse(file(".ssh"), "dblclick")
    2325              : +        b.mouse(file("authorized_keys"), "click")
    2326              : +        b.click(df.apply_button())
    2327              : +        d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
    2328              : +        b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
    2329              : +
    2330              : +        # Select a directory
    2331              : +
    2332              : +        d.wait_FileChooserInput("dir", "")
    2333              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    2334              : +
    2335              : +        b.wait_in_text(".file-chooser-listing-body", "No recent directories")
    2336              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    2337              : +        b.click(file("file-chooser-test"))
    2338              : +        b.click(df.apply_button())
    2339              : +
    2340              : +        d.wait_FileChooserInput("dir", test_files + "/file-chooser-test")
    2341              : +
    2342              : +        b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
    2343              : +        b.wait_visible(file("file-chooser-test"))
    2344              : +        b.click(".file-chooser-sidebar tr:contains('Recent')")
    2345              : +        b.wait_visible(file("file-chooser-test"))
    2346              : +        b.wait_not_present(file("foo"))
    2347              : +        b.wait_in_text(file("file-chooser-test"), test_files)
    2348              : +
    2349              : +        b.wait_visible(df.apply_button() + "[aria-disabled=true]")
    2350              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2351              : +        b.wait_not_present(".file-chooser")
    2352              : +
    2353              : +        # Check mobile layout
    2354              : +
    2355              : +        b.set_layout("mobile")
    2356              : +
    2357              : +        b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
    2358              : +        b.wait_not_visible(".file-chooser-sidebar")
    2359              : +        b.wait_visible(".file-chooser-kebab")
    2360              : +
    2361              : +        b.select_PF(".file-chooser-kebab", "Test files")
    2362              : +        b.mouse(file("file-chooser-test"), "dblclick")
    2363              : +        b.wait_visible(file("empty"))
    2364              : +
    2365              : +        b.click(".file-chooser .pf-v6-c-modal-box__close button")
    2366              : +        b.wait_not_present(".file-chooser")
    2367              : +        b.click(d.cancel_button())
    2368              : +        b.set_layout("desktop")
    2369              : +
    2370              : +        # Stand-alone File Chooser
    2371              : +
    2372              : +        b.click("#open-file-chooser")
    2373              : +        b.wait_visible(".file-chooser")
    2374              : +        b.click(".file-chooser-sidebar tr:contains('Some TXT files')")
    2375              : +        b.wait_visible(file("foo.txt"))
    2376              : +        b.wait_not_present(file("no-such-file.txt"))
    2377              : +        b.click(file("foo.txt"))
    2378              : +        b.wait_text(df.apply_button(), "Load")
    2379              : +        b.click(df.apply_button())
    2380              : +        b.wait_not_present(".file-chooser")
    2381              : +
    2382              : +        b.click("#open-file-chooser")
    2383              : +        b.wait_visible(".file-chooser")
    2384              : +        b.click(".file-chooser-sidebar tr:contains('Test files')")
    2385              : +        b.mouse(file("file-chooser-test"), "dblclick")
    2386              : +        b.mouse(file("text"), "dblclick")
    2387              : +        b.wait_visible(file("foo.txt"))
    2388              : +        b.wait_visible(file("bar.txt"))
    2389              : +        b.click(file("bar.txt"))
    2390              : +        b.click(df.apply_button())
    2391              : +        b.wait_in_text(df.error(), "Does not start with \"foo\"")
    2392              : +        b.click(file("foo.txt"))
    2393              : +        b.click(df.apply_button())
    2394              : +        b.wait_not_present(".file-chooser")
    2395              : +
    2396              :  
    2397              :  if __name__ == '__main__':
    2398              :      testlib.test_main()
    2399              : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
    2400              : new file mode 100644
    2401              : index 000000000..de345c341
    2402              : --- /dev/null
    2403              : +++ b/test/verify/files/file-chooser-test/bar
    2404              : @@ -0,0 +1 @@
    2405              : +Nothing to see.
    2406              : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
    2407              : new file mode 100644
    2408              : index 000000000..0aadcf89b
    2409              : --- /dev/null
    2410              : +++ b/test/verify/files/file-chooser-test/dots.txt
    2411              : @@ -0,0 +1 @@
    2412              : +A file with a dot in its name.
    2413              : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
    2414              : new file mode 100644
    2415              : index 000000000..8159b424a
    2416              : --- /dev/null
    2417              : +++ b/test/verify/files/file-chooser-test/foo
    2418              : @@ -0,0 +1 @@
    2419              : +A file of no consequence.
    2420              : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
    2421              : new file mode 100644
    2422              : index 000000000..896416923
    2423              : --- /dev/null
    2424              : +++ b/test/verify/files/file-chooser-test/foobar
    2425              : @@ -0,0 +1 @@
    2426              : +Can't you think of any other names?
    2427              : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
    2428              : new file mode 100644
    2429              : index 000000000..e69de29bb
    2430              : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
    2431              : new file mode 100644
    2432              : index 000000000..e69de29bb
    2433              : diff --git a/test/verify/files/file-chooser-test/text/bar.txt b/test/verify/files/file-chooser-test/text/bar.txt
    2434              : new file mode 100644
    2435              : index 000000000..ad41127d3
    2436              : --- /dev/null
    2437              : +++ b/test/verify/files/file-chooser-test/text/bar.txt
    2438              : @@ -0,0 +1 @@
    2439              : +No foo here.
    2440              : diff --git a/test/verify/files/file-chooser-test/text/foo.txt b/test/verify/files/file-chooser-test/text/foo.txt
    2441              : new file mode 100644
    2442              : index 000000000..e6f4652aa
    2443              : --- /dev/null
    2444              : +++ b/test/verify/files/file-chooser-test/text/foo.txt
    2445              : @@ -0,0 +1 @@
    2446              : +foo is what I start with
        

Generated by: LCOV version 2.0-1