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 : +
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
|