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