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