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..2c98933bd
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: var(--pf-t--global--text--color--nonstatus--on-blue--default);
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..e281b6aa7
95 : --- /dev/null
96 : +++ b/pkg/lib/cockpit/react/FileChooser.tsx
97 : @@ -0,0 +1,973 @@
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 1 : + ...(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 : + if (entry && entry.type) {
354 1 : + if (!onlyDirectories || entry.type == "dir")
355 1 : + result.push({ type: entry.type, name });
356 1 : + }
357 1 : + }
358 : +
359 1 : + result.sort((a, b) => (a.type + a.name).localeCompare(b.type + b.name));
360 1 : + callback(result);
361 1 : + });
362 : +
363 1 : + return client;
364 1 : +}
365 : +
366 1 : +async function getFileInfos(
367 1 : + paths: string[],
368 1 : + onlyDirectories: boolean,
369 1 : + superuser: cockpit.SuperuserMode,
370 1 : +): Promise<FileInfo[]> {
371 1 : + const res: FileInfo[] = [];
372 : +
373 1 : + for (const p of paths) {
374 1 : + try {
375 1 : + const info = await fsinfo(p, ["type"], superuser ? { superuser } : { });
376 1 : + if (info.type && (!onlyDirectories || info.type == "dir"))
377 1 : + res.push({ name: p, type: info.type });
378 1 : + } catch (ex) {
379 1 : + if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found"))
380 1 : + console.error("Failed to get file type:", p);
381 1 : + }
382 1 : + }
383 : +
384 1 : + return res;
385 1 : +}
386 : +
387 1 : +async function listRecent(recentKey: string): Promise<string[]> {
388 1 : + const recent = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
389 1 : + if (Array.isArray(recent)) {
390 1 : + return recent.filter(r => typeof r == "string");
391 0 : + } else {
392 0 : + return [];
393 0 : + }
394 1 : +}
395 : +
396 1 : +function boldify(name: string, filterText: string): React.ReactNode {
397 1 : + if (!filterText)
398 1 : + return name;
399 1 : + const parts: React.ReactNode[] = [];
400 1 : + let pos;
401 1 : + let key = 0;
402 1 : + while ((pos = name.indexOf(filterText)) >= 0) {
403 1 : + parts.push(name.substring(0, pos));
404 1 : + parts.push(<u key={key++}>{name.substring(pos, pos + filterText.length)}</u>);
405 1 : + name = name.substring(pos + filterText.length);
406 1 : + }
407 1 : + if (name)
408 1 : + parts.push(name);
409 1 : + return parts;
410 1 : +}
411 : +
412 : +export interface FileChooserFilter {
413 : + label: string;
414 : + filter: (name: string, type: string) => boolean,
415 : +}
416 : +
417 : +export interface FileChooserShortcut {
418 : + label: string;
419 : + path: string;
420 : +}
421 : +
422 : +export interface FileChooserCollection {
423 : + label: string;
424 : + emptyLabel: string;
425 : + list: () => Promise<string[]>;
426 : +}
427 : +
428 : +export interface FileChooserProps {
429 : + title: string;
430 : + shortcuts?: undefined | FileChooserShortcut[] | (() => Promise<FileChooserShortcut[]>);
431 : + filters?: undefined | FileChooserFilter[];
432 : + collections?: undefined | FileChooserCollection[] | (() => Promise<FileChooserCollection[]>);
433 : + onlyDirectories?: undefined | boolean;
434 : + superuser?: cockpit.SuperuserMode;
435 : + recentKey?: undefined | string;
436 : + actionLabel?: string;
437 : +}
438 : +
439 : +interface FileChooserValues {
440 : + path: string;
441 : + collection: null | FileChooserCollection;
442 : + files: null | FileError | FileInfo[];
443 : + selected: null | FileInfo;
444 : + textFilter: string;
445 : + filters: FileChooserFilter[];
446 : + filter: FileChooserFilter;
447 : + recent_collection: FileChooserCollection;
448 : + shortcuts: FileChooserShortcut[];
449 : + collections: FileChooserCollection[];
450 : + showHidden: boolean;
451 : +}
452 : +
453 1 : +export const FileChooser = ({
454 1 : + title,
455 1 : + shortcuts = [],
456 1 : + filters = [],
457 1 : + collections = [],
458 1 : + onlyDirectories = false,
459 1 : + superuser,
460 1 : + recentKey = "recent-files",
461 1 : + actionLabel,
462 1 : + path = "",
463 1 : + action,
464 1 : +} : {
465 : + path?: string,
466 : + action: (path: string) => Promise<void>,
467 1 : +} & FileChooserProps) => {
468 1 : + const Dialogs = useDialogs();
469 1 : + const textInputRef = useRef<HTMLInputElement>(null);
470 1 : + const fsInfoClientRef = useRef<FsInfoClient | null>(null);
471 : +
472 1 : + function focusFilter() {
473 1 : + textInputRef.current?.focus();
474 1 : + }
475 : +
476 1 : + useEffect(() => {
477 0 : + textInputRef.current?.focus();
478 1 : + }, []);
479 : +
480 1 : + async function init(): Promise<FileChooserValues> {
481 1 : + const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]);
482 : +
483 1 : + const recent_collection = {
484 1 : + label: _("Recent"),
485 1 : + emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"),
486 1 : + list: () => listRecent(recentKey)
487 1 : + };
488 : +
489 1 : + const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts();
490 1 : + const collections_list = Array.isArray(collections) ? collections : await collections();
491 : +
492 1 : + return {
493 1 : + path,
494 1 : + collection: path == "" ? recent_collection : null,
495 1 : + files: null,
496 1 : + selected: null,
497 1 : + textFilter: "",
498 1 : + filters: all_filters,
499 1 : + filter: all_filters[0],
500 1 : + recent_collection,
501 1 : + shortcuts: await stdShortcuts(shortcuts_list),
502 1 : + collections: collections_list,
503 1 : + showHidden: false,
504 1 : + };
505 1 : + }
506 : +
507 1 : + const dlg = useDialogState_async(init);
508 : +
509 1 : + const setPath = useCallback(
510 1 : + (dlg: DialogState<FileChooserValues>, path: string) => {
511 1 : + dlg.field("path").set(path);
512 1 : + dlg.field("collection").set(null);
513 1 : + dlg.field("selected").set(null);
514 1 : + dlg.field("files").set(null);
515 : +
516 1 : + if (fsInfoClientRef.current)
517 1 : + fsInfoClientRef.current.close();
518 : +
519 1 : + fsInfoClientRef.current = watchFiles(
520 1 : + path,
521 1 : + onlyDirectories,
522 1 : + superuser,
523 1 : + files => {
524 1 : + dlg.field("files").set(files);
525 1 : + }
526 1 : + );
527 1 : + },
528 1 : + [onlyDirectories, superuser],
529 1 : + );
530 : +
531 1 : + const setCollection = useCallback(
532 1 : + (dlg: DialogState<FileChooserValues>, collection: FileChooserCollection) => {
533 1 : + dlg.field("path").set("");
534 1 : + dlg.field("collection").set(collection);
535 1 : + dlg.field("selected").set(null);
536 1 : + dlg.field("files").set(null);
537 : +
538 1 : + if (fsInfoClientRef.current)
539 1 : + fsInfoClientRef.current.close();
540 : +
541 1 : + fsInfoClientRef.current = null;
542 1 : + dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser));
543 1 : + },
544 1 : + [onlyDirectories, superuser],
545 1 : + );
546 : +
547 1 : + useEffect(() => {
548 1 : + if (dlg instanceof DialogState) {
549 1 : + if (dlg.values.collection)
550 1 : + setCollection(dlg, dlg.values.collection);
551 : + else
552 1 : + setPath(dlg, dlg.values.path);
553 1 : + }
554 1 : + return () => {
555 1 : + if (fsInfoClientRef.current)
556 1 : + fsInfoClientRef.current.close();
557 1 : + };
558 1 : + }, [dlg, setPath, setCollection]);
559 : +
560 1 : + function full_path(path: string, selected: string) {
561 1 : + if (path == "")
562 1 : + return selected;
563 : + else
564 1 : + return path_join(path, selected);
565 1 : + }
566 : +
567 1 : + function selected_path(): string | null {
568 1 : + if (!(dlg instanceof DialogState))
569 1 : + return null;
570 : +
571 1 : + const { selected, path } = dlg.values;
572 : +
573 1 : + if (onlyDirectories) {
574 1 : + if (!selected && path != "")
575 1 : + return path;
576 1 : + else if (selected && selected.type == "dir")
577 1 : + return full_path(path, selected.name);
578 1 : + } else {
579 1 : + if (selected && selected.type != "dir")
580 1 : + return full_path(path, selected.name);
581 1 : + }
582 : +
583 1 : + return null;
584 1 : + }
585 : +
586 1 : + async function onAction() {
587 1 : + const full = selected_path();
588 1 : + cockpit.assert(full);
589 1 : + rememberRecent(full, recentKey);
590 1 : + await action(full);
591 1 : + }
592 : +
593 1 : + function breadcrumbs(dlg: DialogState<FileChooserValues>) {
594 1 : + const { path } = dlg.values;
595 : +
596 1 : + if (path == "") {
597 : + // Collection
598 1 : + return null;
599 1 : + } else {
600 1 : + const dirs = ["/"].concat(path.split("/").filter(d => !!d));
601 1 : + const crumbs: React.ReactNode[] = [];
602 1 : + let full = "/";
603 1 : + dirs.forEach((d, i) => {
604 1 : + if (d != "/")
605 1 : + full = path_join(full, d);
606 1 : + const path = full;
607 1 : + crumbs.push(
608 1 : + <BreadcrumbItem
609 1 : + key={i}
610 1 : + to="#"
611 1 : + onClick={
612 1 : + (event) => {
613 1 : + setPath(dlg, path);
614 1 : + event.preventDefault();
615 1 : + }
616 : + }
617 1 : + isActive={i == dirs.length - 1}
618 : + >
619 1 : + { d == "/" ? <OutlinedHddIcon className="breadcrumb-hdd-icon" /> : d }
620 1 : + </BreadcrumbItem>
621 1 : + );
622 1 : + });
623 : +
624 1 : + if (crumbs.length > 0) {
625 1 : + return (
626 1 : + <Breadcrumb>
627 1 : + {crumbs}
628 1 : + </Breadcrumb>
629 : + );
630 1 : + }
631 1 : + }
632 1 : + }
633 : +
634 1 : + function header(dlg: DialogState<FileChooserValues>) {
635 1 : + const preparedFilters = (
636 1 : + dlg.values.filters.length > 1 &&
637 1 : + <ToggleGroup>
638 : + {
639 1 : + dlg.values.filters.map(f => {
640 1 : + return (
641 1 : + <ToggleGroupItem
642 1 : + key={f.label}
643 1 : + isSelected={f == dlg.values.filter}
644 1 : + onChange={() => {
645 1 : + dlg.field("filter").set(f);
646 1 : + focusFilter();
647 1 : + }}
648 1 : + text={f.label}
649 1 : + />
650 : + );
651 1 : + })
652 : + }
653 1 : + </ToggleGroup>
654 : + );
655 : +
656 1 : + const textFilter = (
657 1 : + <TextInput
658 1 : + ref={textInputRef}
659 1 : + placeholder={_("Type to filter")}
660 1 : + value={dlg.values.textFilter}
661 1 : + onChange={(_event, value) => dlg.field("textFilter").set(value)}
662 1 : + />
663 : + );
664 : +
665 1 : + function shortcut(sc: FileChooserShortcut) {
666 1 : + return (
667 1 : + <DropdownItem
668 1 : + key={sc.label}
669 1 : + onClick={() => setPath(dlg, sc.path)}
670 1 : + className="file-chooser-hide-on-wide"
671 : + >
672 1 : + {sc.label}
673 1 : + </DropdownItem>
674 : + );
675 1 : + }
676 : +
677 1 : + function collection(cl: FileChooserCollection) {
678 1 : + return (
679 1 : + <DropdownItem
680 1 : + key={cl.label}
681 0 : + onClick={() => setCollection(dlg, cl)}
682 1 : + className="file-chooser-hide-on-wide"
683 : + >
684 1 : + {cl.label}
685 1 : + </DropdownItem>
686 : + );
687 1 : + }
688 : +
689 1 : + return (
690 1 : + <Flex>
691 1 : + <FlexItem>
692 1 : + {textFilter}
693 1 : + </FlexItem>
694 1 : + <FlexItem>
695 1 : + {preparedFilters}
696 1 : + </FlexItem>
697 1 : + <FlexItem className="file-chooser-kebab" align={{ default: 'alignRight' }}>
698 1 : + <KebabDropdown
699 1 : + dropdownItems={
700 1 : + [
701 1 : + <DropdownItem
702 1 : + key="jump"
703 1 : + onClick={
704 0 : + () => {
705 0 : + cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path }));
706 0 : + }
707 : + }
708 1 : + isDisabled={dlg.values.path === ""}
709 : + >
710 1 : + {_("Open in file browser")}
711 1 : + </DropdownItem>,
712 1 : + <DropdownItem
713 1 : + key="showhide"
714 1 : + onClick={
715 1 : + () => {
716 1 : + dlg.field("showHidden").set(!dlg.values.showHidden);
717 1 : + }
718 : + }
719 : + >
720 1 : + {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")}
721 1 : + </DropdownItem>,
722 1 : + <Divider key="divider" className="file-chooser-hide-on-wide" />,
723 1 : + collection(dlg.values.recent_collection),
724 1 : + ...dlg.values.shortcuts.map(shortcut),
725 1 : + shortcut({ label: _("Filesystem"), path: "/" }),
726 1 : + ...dlg.values.collections.map(collection)
727 1 : + ]
728 : + }
729 1 : + />
730 1 : + </FlexItem>
731 1 : + </Flex>
732 : + );
733 1 : + }
734 : +
735 1 : + function formatIcon(f: FileInfo): React.ReactNode {
736 1 : + if (f.type == "dir")
737 1 : + return <FolderIcon />;
738 : + else
739 1 : + return <FileIcon />;
740 1 : + }
741 : +
742 1 : + function sidebar(dlg: DialogState<FileChooserValues>) {
743 1 : + function shortcut(sc: FileChooserShortcut) {
744 1 : + return (
745 1 : + <Tr
746 1 : + key={sc.label}
747 1 : + isClickable
748 1 : + isSelectable
749 1 : + isRowSelected={dlg.values.path == sc.path}
750 1 : + onRowClick={
751 1 : + () => {
752 1 : + setPath(dlg, sc.path);
753 1 : + focusFilter();
754 1 : + }
755 : + }
756 : + >
757 1 : + <Td>{sc.label}</Td>
758 1 : + </Tr>
759 : + );
760 1 : + }
761 : +
762 1 : + function collection(col: FileChooserCollection) {
763 1 : + return (
764 1 : + <Tr
765 1 : + key={col.label}
766 1 : + isClickable
767 1 : + isSelectable
768 1 : + isRowSelected={dlg.values.collection == col}
769 1 : + onRowClick={
770 1 : + () => {
771 1 : + setCollection(dlg, col);
772 1 : + focusFilter();
773 1 : + }
774 : + }
775 : + >
776 1 : + <Td>{col.label}</Td>
777 1 : + </Tr>
778 : + );
779 1 : + }
780 : +
781 1 : + return (
782 1 : + <Table variant="compact" borders={false}>
783 1 : + <Tbody>
784 1 : + { collection(dlg.values.recent_collection) }
785 1 : + { dlg.values.shortcuts.map(shortcut) }
786 1 : + { shortcut({ label: _("Filesystem"), path: "/" }) }
787 1 : + { dlg.values.collections.map(collection) }
788 1 : + </Tbody>
789 1 : + </Table>
790 : + );
791 1 : + }
792 : +
793 1 : + function listing(dlg: DialogState<FileChooserValues>) {
794 1 : + function emptyState(content: string, icon: NonNullable<EmptyStateProps["icon"]>, clearFilters: number = 0) {
795 1 : + return (
796 1 : + <Caption>
797 1 : + <EmptyState
798 1 : + titleText={content}
799 1 : + icon={icon}
800 : + >
801 1 : + { (clearFilters > 0) &&
802 1 : + <EmptyStateActions>
803 1 : + <Button
804 1 : + variant="link"
805 1 : + onClick={() => {
806 1 : + if (clearFilters == 3) {
807 1 : + dlg.field("showHidden").set(true);
808 1 : + } else {
809 1 : + dlg.field("textFilter").set("");
810 1 : + if (clearFilters > 1)
811 1 : + dlg.field("filter").set(dlg.values.filters[dlg.values.filters.length - 1]);
812 1 : + }
813 1 : + focusFilter();
814 1 : + }}
815 : + >
816 1 : + {clearFilters == 3 ? _("Show hidden files") : _("Clear filters")}
817 1 : + </Button>
818 1 : + </EmptyStateActions>
819 : + }
820 1 : + </EmptyState>
821 1 : + </Caption>
822 : + );
823 1 : + }
824 : +
825 1 : + function listingBody() {
826 1 : + const files = dlg.values.files;
827 : +
828 1 : + if (files == null)
829 1 : + return emptyState("", Spinner);
830 : +
831 1 : + if (files instanceof FileError)
832 1 : + return emptyState(files.message, FolderIcon);
833 : +
834 1 : + if (files.length == 0) {
835 1 : + if (dlg.values.collection) {
836 1 : + return emptyState(dlg.values.collection.emptyLabel, FolderIcon);
837 1 : + } else if (!onlyDirectories) {
838 1 : + return emptyState(_("Directory is empty"), FolderIcon);
839 0 : + } else {
840 0 : + return emptyState(_("Directory has no sub-directories"), FolderIcon);
841 0 : + }
842 1 : + }
843 : +
844 1 : + const withoutHidden = dlg.values.showHidden ? files : files.filter(f => f.name[0] !== ".");
845 1 : + if (withoutHidden.length == 0)
846 1 : + return emptyState(_("This directory contains only hidden files"), SearchIcon, 3);
847 : +
848 1 : + const preFiltered = withoutHidden.filter(
849 1 : + f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
850 1 : + );
851 1 : + if (preFiltered.length == 0)
852 1 : + return emptyState(_("No matching results"), SearchIcon, 2);
853 : +
854 1 : + const filtered = preFiltered.filter(f => f.name.includes(dlg.values.textFilter));
855 1 : + if (filtered.length == 0)
856 1 : + return emptyState(_("No matching results"), SearchIcon, 1);
857 : +
858 1 : + return (
859 1 : + <Tbody>
860 : + {
861 1 : + filtered.map(
862 1 : + (f, idx) => {
863 1 : + let name, location;
864 1 : + if (dlg.values.path == "") {
865 1 : + name = basename(f.name);
866 1 : + location = dirname(f.name);
867 1 : + } else {
868 1 : + name = f.name;
869 1 : + }
870 1 : + return (
871 1 : + <Tr
872 1 : + className={f.name == dlg.values.selected?.name ? "file-chooser-selected" : ""}
873 1 : + key={idx}
874 1 : + data-name={name}
875 1 : + onRowClick={
876 1 : + () => {
877 1 : + dlg.field("selected").set(f);
878 1 : + focusFilter();
879 1 : + }
880 : + }
881 1 : + onDoubleClick={
882 1 : + event => {
883 1 : + event.preventDefault();
884 1 : + if (f.type == "dir")
885 1 : + setPath(dlg, full_path(dlg.values.path, f.name));
886 1 : + dlg.field("textFilter").set("");
887 1 : + focusFilter();
888 1 : + }
889 : + }
890 1 : + isClickable
891 : + >
892 1 : + <Td>
893 1 : + {formatIcon(f)}
894 : +
895 1 : + {boldify(name, dlg.values.textFilter)}
896 1 : + </Td>
897 1 : + { location && <Td>{location}</Td> }
898 1 : + </Tr>
899 : + );
900 1 : + }
901 1 : + )
902 : + }
903 1 : + </Tbody>
904 : + );
905 1 : + }
906 : +
907 1 : + return (
908 1 : + <Table variant="compact" borders={false}>
909 1 : + { listingBody() }
910 1 : + </Table>
911 : + );
912 1 : + }
913 : +
914 1 : + return (
915 1 : + <Modal
916 1 : + isOpen
917 1 : + variant="large"
918 1 : + position="top"
919 1 : + onClose={Dialogs.close}
920 1 : + className="file-chooser"
921 : + >
922 1 : + <ModalHeader
923 1 : + title={title}
924 1 : + description={<DialogErrorMessage dialog={dlg} />}
925 1 : + />
926 1 : + <ModalBody>
927 1 : + <div className="file-chooser-body">
928 1 : + <div className="file-chooser-sidebar file-chooser-hide-on-narrow">
929 : + {
930 1 : + dlg instanceof DialogState
931 1 : + ? sidebar(dlg)
932 1 : + : <Bullseye><Spinner /></Bullseye>
933 : + }
934 1 : + </div>
935 1 : + <div className="file-chooser-listing-header">
936 1 : + { dlg instanceof DialogState && header(dlg) }
937 1 : + </div>
938 1 : + <div className="file-chooser-listing-breadcrumbs">
939 1 : + { dlg instanceof DialogState && breadcrumbs(dlg) }
940 1 : + </div>
941 1 : + <div className="file-chooser-listing-body">
942 1 : + { dlg instanceof DialogState && listing(dlg) }
943 1 : + </div>
944 1 : + </div>
945 1 : + </ModalBody>
946 1 : + <ModalFooter>
947 1 : + <DialogActionButton
948 1 : + dialog={dlg}
949 1 : + isAriaDisabled={selected_path() === null}
950 1 : + action={onAction}
951 1 : + onClose={Dialogs.close}
952 : + >
953 1 : + {actionLabel || _("Select")}
954 1 : + </DialogActionButton>
955 1 : + </ModalFooter>
956 1 : + </Modal>
957 : + );
958 1 : +};
959 : +
960 2 : +const FileChooserButton = ({
961 2 : + value,
962 2 : + onChoose,
963 2 : + props,
964 2 : +} : {
965 : + value: string,
966 : + onChoose: (path: string) => void,
967 : + props: FileChooserProps,
968 2 : +}) => {
969 2 : + const Dialogs = useDialogs();
970 : +
971 2 : + return (
972 2 : + <Button
973 2 : + variant="plain"
974 2 : + icon={<FolderOpenIcon />}
975 2 : + onClick={
976 1 : + async () => {
977 1 : + Dialogs.show(
978 1 : + <FileChooser
979 1 : + path={value[0] == "/" ? (props.onlyDirectories ? value : dirname(value)) : ""}
980 1 : + action={async path => onChoose(path)}
981 1 : + {...props}
982 1 : + />
983 1 : + );
984 1 : + }
985 : + }
986 2 : + />
987 : + );
988 2 : +};
989 : +
990 2 : +export const FileChooserInput = ({
991 2 : + ouiaId,
992 2 : + placeholder = "",
993 2 : + value,
994 2 : + onChange,
995 2 : + isDisabled = false,
996 2 : + fileChooserProps,
997 2 : +} : {
998 : + ouiaId?: undefined | string;
999 : + placeholder?: string,
1000 : + value: string,
1001 : + onChange: (path: string, from_dialog: boolean) => void,
1002 : + isDisabled?: boolean,
1003 : + fileChooserProps: FileChooserProps,
1004 2 : +}) => {
1005 2 : + return (
1006 2 : + <TextInputGroup
1007 2 : + isDisabled={isDisabled}
1008 2 : + data-ouia-component-id={ouiaId}
1009 : + >
1010 2 : + <TextInputGroupMain
1011 2 : + value={value}
1012 2 : + placeholder={placeholder}
1013 1 : + onChange={(_event, value) => onChange(value, false)}
1014 2 : + autoComplete="off"
1015 2 : + />
1016 2 : + <TextInputGroupUtilities>
1017 2 : + <WithDialogs>
1018 2 : + <FileChooserButton
1019 2 : + value={value}
1020 1 : + onChoose={value => onChange(value, true)}
1021 2 : + props={fileChooserProps}
1022 2 : + />
1023 2 : + </WithDialogs>
1024 2 : + </TextInputGroupUtilities>
1025 2 : + </TextInputGroup>
1026 : + );
1027 2 : +};
1028 : +
1029 2 : +export const DialogFileChooserInput = ({
1030 2 : + field,
1031 2 : + label,
1032 2 : + placeholder = "",
1033 2 : + explanation,
1034 2 : + warning,
1035 2 : + excuse,
1036 2 : + fileChooserProps,
1037 2 : +} : {
1038 : + field: DialogField<string>,
1039 : + label: string,
1040 : + placeholder?: string,
1041 : + explanation?: React.ReactNode,
1042 : + warning?: React.ReactNode,
1043 : + excuse?: string | null | undefined | false,
1044 : + fileChooserProps: FileChooserProps,
1045 2 : +}) => {
1046 2 : + return (
1047 2 : + <OptionalFormGroup
1048 2 : + label={label}
1049 : + >
1050 2 : + <FileChooserInput
1051 2 : + ouiaId={field.ouia_id()}
1052 2 : + placeholder={placeholder}
1053 2 : + value={field.get()}
1054 1 : + onChange={(val, from_dialog) => field.set_debounced(val, from_dialog ? 0 : undefined)}
1055 2 : + isDisabled={!!excuse}
1056 2 : + fileChooserProps={fileChooserProps}
1057 2 : + />
1058 2 : + <DialogHelperText field={field} explanation={explanation} warning={warning} excuse={excuse} />
1059 2 : + </OptionalFormGroup>
1060 : + );
1061 2 : +};
1062 : +
1063 1 : +export function rememberRecent(name: string, recentKey: string = "recent-files") {
1064 1 : + const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]");
1065 1 : + if (Array.isArray(value)) {
1066 1 : + const recent = value.filter(r => typeof r == "string" && r != name);
1067 1 : + recent.unshift(name);
1068 1 : + window.localStorage.setItem(recentKey, JSON.stringify(recent.slice(0, 20)));
1069 1 : + }
1070 1 : +}
1071 : diff --git a/pkg/playground/dialog.tsx b/pkg/playground/dialog.tsx
1072 : index 58f890d56..3d3ee4a8d 100644
1073 : --- a/pkg/playground/dialog.tsx
1074 : +++ b/pkg/playground/dialog.tsx
1075 : @@ -36,6 +36,8 @@ import {
1076 : DialogActionButton, DialogCancelButton,
1077 : } from 'cockpit/dialog';
1078 :
1079 : +import { FileChooser, DialogFileChooserInput } from "cockpit/react/FileChooser";
1080 : +
1081 : import 'cockpit-dark-theme'; // once per page
1082 : import 'page.scss';
1083 :
1084 : @@ -218,6 +220,9 @@ interface ExampleValues {
1085 : alternative: false | string;
1086 : error: string;
1087 : allow_force: boolean;
1088 : + file: string;
1089 : + file_explanation: string;
1090 : + dir: string;
1091 : }
1092 :
1093 : const ExampleDialog = ({
1094 : @@ -246,6 +251,9 @@ const ExampleDialog = ({
1095 : alternative: false,
1096 : error: "none",
1097 : allow_force: false,
1098 2 : + file: "",
1099 2 : + file_explanation: "",
1100 2 : + dir: "",
1101 : };
1102 :
1103 : function validate(dlg: DialogState<ExampleValues>) {
1104 : @@ -270,6 +278,10 @@ const ExampleDialog = ({
1105 : });
1106 : });
1107 : dlg.field("async").forEach(v => validate_Name(v, countAsyncValidation));
1108 1 : + dlg.field("file").validate(v => {
1109 0 : + if (v && v[0] != "/")
1110 0 : + return "Must be absolute";
1111 1 : + });
1112 : }
1113 :
1114 : const dlg = useDialogState(init, validate);
1115 : @@ -317,6 +329,15 @@ const ExampleDialog = ({
1116 : });
1117 : }
1118 :
1119 1 : + function file_changed(val: string) {
1120 1 : + dlg.field("file_explanation").set_async(async () => {
1121 1 : + if (val[0] == "/")
1122 1 : + return cockpit.spawn(["file", "-b", val], { superuser: "try" });
1123 : + else
1124 1 : + return "--";
1125 1 : + });
1126 1 : + }
1127 : +
1128 : return (
1129 : <Modal
1130 : id="dialog"
1131 : @@ -413,6 +434,49 @@ const ExampleDialog = ({
1132 : checkbox_label="Allow force"
1133 : field={dlg.field("allow_force")}
1134 : />
1135 2 : + <DialogFileChooserInput
1136 2 : + label="File"
1137 2 : + field={dlg.field("file", file_changed)}
1138 2 : + explanation={dlg.values.file_explanation}
1139 2 : + fileChooserProps={
1140 2 : + {
1141 2 : + title: "Select a file",
1142 2 : + superuser: "try",
1143 2 : + filters: [
1144 1 : + { label: "No dots", filter: n => !n.includes(".") },
1145 2 : + ],
1146 2 : + shortcuts: [
1147 2 : + { label: "Test files", path: "/var/lib/cockpittest" }
1148 2 : + ],
1149 2 : + collections: [
1150 2 : + {
1151 2 : + label: "Some files",
1152 2 : + emptyLabel: "Nothing there",
1153 1 : + list: async () => {
1154 1 : + return [
1155 1 : + "/var/lib/cockpittest/file-chooser-test/dots.txt",
1156 1 : + "/var/lib/cockpittest/file-chooser-test/foo",
1157 1 : + ];
1158 1 : + }
1159 2 : + }
1160 2 : + ]
1161 2 : + }
1162 : + }
1163 2 : + />
1164 2 : + <DialogFileChooserInput
1165 2 : + label="Directory"
1166 2 : + field={dlg.field("dir")}
1167 2 : + fileChooserProps={
1168 2 : + {
1169 2 : + title: "Select a directory",
1170 2 : + onlyDirectories: true,
1171 2 : + superuser: "try",
1172 2 : + shortcuts: [
1173 2 : + { label: "Test files", path: "/var/lib/cockpittest" }
1174 2 : + ],
1175 2 : + }
1176 : + }
1177 2 : + />
1178 : </Form>
1179 : </ModalBody>
1180 : <ModalFooter>
1181 : @@ -695,6 +759,65 @@ const SimpleExampleButtons = () => {
1182 : );
1183 : };
1184 :
1185 2 : +const FileChooserButton = () => {
1186 2 : + const Dialogs = useDialogs();
1187 : +
1188 1 : + async function loadFile(path: string) {
1189 1 : + const data = await cockpit.file(path).read();
1190 1 : + if (!data.startsWith("foo"))
1191 1 : + throw new Error("Does not start with \"foo\"");
1192 1 : + }
1193 : +
1194 2 : + return (
1195 2 : + <Button
1196 2 : + id="open-file-chooser"
1197 2 : + onClick={
1198 1 : + () => Dialogs.show(
1199 1 : + <FileChooser
1200 1 : + title={"Select a file that starts with \"foo\""}
1201 1 : + actionLabel="Load"
1202 1 : + action={loadFile}
1203 1 : + filters={
1204 1 : + [
1205 1 : + {
1206 1 : + label: "TXT files",
1207 1 : + filter: (name, type) => type == "reg" && !!name.match("\\.txt$")
1208 1 : + },
1209 1 : + ]
1210 : + }
1211 1 : + shortcuts={
1212 1 : + async () => {
1213 1 : + await async_sleep(500);
1214 1 : + return [
1215 1 : + { label: "Test files", path: "/var/lib/cockpittest" }
1216 1 : + ];
1217 1 : + }
1218 : + }
1219 1 : + collections={
1220 1 : + async () => {
1221 1 : + return [
1222 1 : + {
1223 1 : + label: "Some TXT files",
1224 1 : + emptyLabel: "Nothing there",
1225 1 : + list: async () => {
1226 1 : + return [
1227 1 : + "/var/lib/cockpittest/file-chooser-test/text/foo.txt",
1228 1 : + "/var/lib/cockpittest/file-chooser-test/no-such-file.txt"
1229 1 : + ];
1230 1 : + }
1231 1 : + }
1232 1 : + ];
1233 1 : + }
1234 : + }
1235 1 : + />
1236 1 : + )
1237 : + }
1238 2 : + >
1239 : + Open FileChooser
1240 2 : + </Button>
1241 : + );
1242 2 : +};
1243 : +
1244 : const Demo = () => {
1245 : return (
1246 : <WithDialogs>
1247 : @@ -702,6 +825,7 @@ const Demo = () => {
1248 : <PageSection>
1249 : <ExampleButton />
1250 : <SimpleExampleButtons />
1251 2 : + <FileChooserButton />
1252 : </PageSection>
1253 : </Page>
1254 : </WithDialogs>
1255 : diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py
1256 : index d9f040327..b2f5981c7 100644
1257 : --- a/test/common/dialoglib.py
1258 : +++ b/test/common/dialoglib.py
1259 : @@ -148,3 +148,14 @@ class DialogHelpers:
1260 :
1261 : def set_DropdownSelect(self, path: str, val: str) -> None:
1262 : self.browser.select_from_dropdown(self.field(path), val)
1263 : +
1264 : + # FileChooserInput
1265 : +
1266 : + def get_FileChooserInput(self, path: str) -> str:
1267 : + return self.browser.val(self.field(path) + " input")
1268 : +
1269 : + def wait_FileChooserInput(self, path: str, val: str):
1270 : + self.browser.wait_val(self.field(path) + " input", val)
1271 : +
1272 : + def set_FileChooserInput(self, path: str, val: str) -> None:
1273 : + self.browser.set_input_text(self.field(path) + " input", val)
1274 : diff --git a/test/verify/check-dialog b/test/verify/check-dialog
1275 : index f614daa33..d04db10d6 100755
1276 : --- a/test/verify/check-dialog
1277 : +++ b/test/verify/check-dialog
1278 : @@ -350,6 +350,283 @@ class TestDialog(testlib.MachineCase):
1279 : b.click(d.cancel_button())
1280 : b.wait_not_present("#dialog")
1281 :
1282 : + def testFileChooser(self):
1283 : + b = self.browser
1284 : + m = self.machine
1285 : + d = dialoglib.DialogHelpers(b, "#dialog")
1286 : + df = dialoglib.DialogHelpers(b, ".file-chooser")
1287 : +
1288 : + # Inject a mock xdg-user-dir utility.
1289 : +
1290 : + self.write_file("/usr/local/bin/xdg-user-dir",
1291 : +"""#! /bin/sh
1292 : +echo $HOME/Downloads
1293 : +""", perm="a+x")
1294 : +
1295 : + # Where our test files are. This is intended to be the same as
1296 : + # self.vm_tmpdir, but it is also hard-coded into
1297 : + # pkg/playground/dialog.tsx and so we hard-code it here as
1298 : + # well.
1299 : +
1300 : + test_files = "/var/lib/cockpittest"
1301 : +
1302 : + self.login_and_go("/playground/dialog", superuser=False)
1303 : +
1304 : + b.click("#open")
1305 : +
1306 : + # Use the get_FileChooserInput method so that Vulture doesn't
1307 : + # complain about it being unused.
1308 : +
1309 : + self.assertEqual(d.get_FileChooserInput("file"), "")
1310 : +
1311 : + # The first open has a empty Recent tab.
1312 : +
1313 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1314 : + b.wait_in_text(".file-chooser-listing-body", "No recent files")
1315 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1316 : + b.wait_not_present(".file-chooser")
1317 : +
1318 : + # Basic interaction with the text input
1319 : +
1320 : + d.set_FileChooserInput("file", "/home/non-existent/foo")
1321 : + b.wait_in_text(d.helper_text("file"), "(No such file or directory)")
1322 : +
1323 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1324 : + b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
1325 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1326 : + b.wait_not_present(".file-chooser")
1327 : +
1328 : + m.upload(["verify/files/file-chooser-test/"], test_files)
1329 : + m.execute(f"mkdir '{test_files}/file-chooser-test/empty'")
1330 : + d.set_FileChooserInput("file", test_files)
1331 : + b.wait_in_text(d.helper_text("file"), "directory")
1332 : +
1333 : + def file(name):
1334 : + return f".file-chooser-listing-body tr[data-name='{name}']"
1335 : +
1336 : + # Navigate to empty directory
1337 : +
1338 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1339 : + b.wait_visible(".file-chooser")
1340 : + b.mouse(file("cockpittest"), "dblclick")
1341 : + b.mouse(file("file-chooser-test"), "dblclick")
1342 : + b.mouse(file("empty"), "dblclick")
1343 : + b.wait_in_text(".file-chooser-listing-body", "Directory is empty")
1344 : +
1345 : + # Go up and choose tmpdir/file-chooser-test/foo
1346 : +
1347 : + b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')")
1348 : + b.assert_pixels(".file-chooser", "basic")
1349 : + b.mouse(file("foo"), "click")
1350 : + b.click(df.apply_button())
1351 : +
1352 : + d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
1353 : + b.wait_in_text(d.helper_text("file"), "ASCII text")
1354 : +
1355 : + # "foo" should now be in "Recent"
1356 : +
1357 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1358 : + b.wait_visible(".file-chooser-listing-breadcrumbs nav")
1359 : + b.wait_visible(file("foo"))
1360 : + b.click(".file-chooser-sidebar tr:contains('Recent')")
1361 : + b.wait_not_present(".file-chooser-listing-breadcrumbs nav")
1362 : + b.wait_visible(file("foo"))
1363 : + b.wait_in_text(file("foo"), test_files + "/file-chooser-test")
1364 : + b.mouse(file("foo"), "click")
1365 : + b.click(df.apply_button())
1366 : + d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo")
1367 : + b.wait_in_text(d.helper_text("file"), "ASCII text")
1368 : +
1369 : + # Check that "Home" has some expected files
1370 : +
1371 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1372 : + b.click(".file-chooser-sidebar tr:contains('Home')")
1373 : + # home can be /home/admin or /var/home/admin.
1374 : + b.wait_in_text(".file-chooser-listing-breadcrumbs", "homeadmin")
1375 : + b.click(".file-chooser-listing-breadcrumbs a:contains('home')")
1376 : + b.mouse(file("admin"), "dblclick")
1377 : + b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files")
1378 : + b.click(".file-chooser-listing-body button:contains('Show hidden files')")
1379 : + b.mouse(file(".ssh"), "dblclick")
1380 : + b.mouse(file("authorized_keys"), "click")
1381 : + b.click(df.apply_button())
1382 : +
1383 : + d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys")
1384 : + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
1385 : +
1386 : + # Check the "Downloads" shortcut
1387 : +
1388 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1389 : + b.click(".file-chooser-sidebar tr:contains('Downloads')")
1390 : + b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads")
1391 : + b.wait_in_text(".file-chooser-listing-body", "No such file or directory")
1392 : +
1393 : + # Check that we can't read /root
1394 : +
1395 : + b.click(".file-chooser-sidebar tr:contains('Filesystem')")
1396 : + b.mouse(file("root"), "dblclick")
1397 : + b.wait_in_text(".file-chooser-listing-body", "Permission denied")
1398 : + b.assert_pixels(".file-chooser", "denied")
1399 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1400 : + b.wait_not_present(".file-chooser")
1401 : +
1402 : + # Free text filtering
1403 : +
1404 : + d.set_FileChooserInput("file", "")
1405 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1406 : + b.click(".file-chooser-sidebar tr:contains('Test files')")
1407 : + b.mouse(file("file-chooser-test"), "dblclick")
1408 : +
1409 : + b.wait_visible(file("bar"))
1410 : + b.wait_visible(file("foo"))
1411 : + b.wait_visible(file("foobar"))
1412 : +
1413 : + b.set_input_text(".file-chooser-listing-header input", "fo")
1414 : + b.wait_visible(file("foo"))
1415 : + b.wait_not_present(file("bar"))
1416 : + b.wait_visible(file("foobar"))
1417 : + b.assert_pixels(".file-chooser", "filtered")
1418 : +
1419 : + b.set_input_text(".file-chooser-listing-header input", "ba")
1420 : + b.wait_not_present(file("foo"))
1421 : + b.wait_visible(file("bar"))
1422 : + b.wait_visible(file("foobar"))
1423 : +
1424 : + b.set_input_text(".file-chooser-listing-header input", "xxx")
1425 : + b.wait_in_text(".file-chooser-listing-body", "No matching results")
1426 : + b.click(".file-chooser-listing-body button:contains('Clear filters')")
1427 : +
1428 : + b.wait_visible(file("bar"))
1429 : + b.wait_visible(file("foo"))
1430 : + b.wait_visible(file("foobar"))
1431 : +
1432 : + # Prepared filtering.
1433 : +
1434 : + # "No dots" was already active all the time, switch it off to
1435 : + # reveal more files.
1436 : +
1437 : + b.click(".file-chooser-listing-header button:contains('All files')")
1438 : +
1439 : + b.wait_visible(file("bar"))
1440 : + b.wait_visible(file("foo"))
1441 : + b.wait_visible(file("foobar"))
1442 : + b.wait_visible(file("dots.txt"))
1443 : + b.wait_visible(file("only.dots"))
1444 : +
1445 : + b.mouse(file("only.dots"), "dblclick")
1446 : + b.wait_visible(file("one.dot"))
1447 : + b.wait_visible(file("two.dots"))
1448 : +
1449 : + b.click(".file-chooser-listing-header button:contains('No dots')")
1450 : +
1451 : + b.wait_in_text(".file-chooser-listing-body", "No matching results")
1452 : +
1453 : + # Filter even more, this should get cleared as well
1454 : + b.set_input_text(".file-chooser-listing-header input", "x")
1455 : +
1456 : + b.click(".file-chooser-listing-body button:contains('Clear filters')")
1457 : +
1458 : + b.wait_visible(file("one.dot"))
1459 : + b.wait_visible(file("two.dots"))
1460 : +
1461 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1462 : + b.wait_not_present(".file-chooser")
1463 : +
1464 : + # Test the collection
1465 : +
1466 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1467 : + b.click(".file-chooser-sidebar tr:contains('Some files')")
1468 : + b.wait_visible(file("foo"))
1469 : + b.wait_not_present(file("dots.txt"))
1470 : + b.click(".file-chooser-listing-header button:contains('All files')")
1471 : + b.wait_visible(file("foo"))
1472 : + b.wait_visible(file("dots.txt"))
1473 : + b.click(file("dots.txt"))
1474 : + b.click(df.apply_button())
1475 : +
1476 : + d.wait_FileChooserInput("file", "/var/lib/cockpittest/file-chooser-test/dots.txt")
1477 : + b.wait_in_text(d.helper_text("file"), "ASCII text")
1478 : +
1479 : + # Become superuser and access /root/.ssh
1480 : +
1481 : + b.become_superuser()
1482 : +
1483 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1484 : + b.click(".file-chooser-sidebar tr:contains('Filesystem')")
1485 : + b.select_PF(".file-chooser-kebab", "Show hidden files")
1486 : + b.mouse(file("root"), "dblclick")
1487 : + b.mouse(file(".ssh"), "dblclick")
1488 : + b.mouse(file("authorized_keys"), "click")
1489 : + b.click(df.apply_button())
1490 : + d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys")
1491 : + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key")
1492 : +
1493 : + # Select a directory
1494 : +
1495 : + d.wait_FileChooserInput("dir", "")
1496 : + b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
1497 : +
1498 : + b.wait_in_text(".file-chooser-listing-body", "No recent directories")
1499 : + b.click(".file-chooser-sidebar tr:contains('Test files')")
1500 : + b.click(file("file-chooser-test"))
1501 : + b.click(df.apply_button())
1502 : +
1503 : + d.wait_FileChooserInput("dir", test_files + "/file-chooser-test")
1504 : +
1505 : + b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button")
1506 : + b.wait_visible(file("only.dots"))
1507 : + b.click(".file-chooser-sidebar tr:contains('Recent')")
1508 : + b.wait_visible(file("file-chooser-test"))
1509 : + b.wait_not_present(file("foo"))
1510 : + b.wait_in_text(file("file-chooser-test"), test_files)
1511 : +
1512 : + b.wait_visible(df.apply_button() + "[aria-disabled=true]")
1513 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1514 : + b.wait_not_present(".file-chooser")
1515 : +
1516 : + # Check mobile layout
1517 : +
1518 : + b.set_layout("mobile")
1519 : +
1520 : + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button")
1521 : + b.wait_not_visible(".file-chooser-sidebar")
1522 : + b.wait_visible(".file-chooser-kebab")
1523 : +
1524 : + b.select_PF(".file-chooser-kebab", "Test files")
1525 : + b.mouse(file("file-chooser-test"), "dblclick")
1526 : + b.wait_visible(file("empty"))
1527 : +
1528 : + b.click(".file-chooser .pf-v6-c-modal-box__close button")
1529 : + b.wait_not_present(".file-chooser")
1530 : + b.click(d.cancel_button())
1531 : + b.set_layout("desktop")
1532 : +
1533 : + # Stand-alone File Chooser
1534 : +
1535 : + b.click("#open-file-chooser")
1536 : + b.wait_visible(".file-chooser")
1537 : + b.click(".file-chooser-sidebar tr:contains('Some TXT files')")
1538 : + b.wait_visible(file("foo.txt"))
1539 : + b.wait_not_present(file("no-such-file.txt"))
1540 : + b.click(file("foo.txt"))
1541 : + b.wait_text(df.apply_button(), "Load")
1542 : + b.click(df.apply_button())
1543 : + b.wait_not_present(".file-chooser")
1544 : +
1545 : + b.click("#open-file-chooser")
1546 : + b.wait_visible(".file-chooser")
1547 : + b.click(".file-chooser-sidebar tr:contains('Test files')")
1548 : + b.mouse(file("file-chooser-test"), "dblclick")
1549 : + b.mouse(file("text"), "dblclick")
1550 : + b.wait_visible(file("foo.txt"))
1551 : + b.wait_visible(file("bar.txt"))
1552 : + b.click(file("bar.txt"))
1553 : + b.click(df.apply_button())
1554 : + b.wait_in_text(df.error(), "Does not start with \"foo\"")
1555 : + b.click(file("foo.txt"))
1556 : + b.click(df.apply_button())
1557 : + b.wait_not_present(".file-chooser")
1558 : +
1559 :
1560 : if __name__ == '__main__':
1561 : testlib.test_main()
1562 : diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar
1563 : new file mode 100644
1564 : index 000000000..de345c341
1565 : --- /dev/null
1566 : +++ b/test/verify/files/file-chooser-test/bar
1567 : @@ -0,0 +1 @@
1568 : +Nothing to see.
1569 : diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt
1570 : new file mode 100644
1571 : index 000000000..0aadcf89b
1572 : --- /dev/null
1573 : +++ b/test/verify/files/file-chooser-test/dots.txt
1574 : @@ -0,0 +1 @@
1575 : +A file with a dot in its name.
1576 : diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo
1577 : new file mode 100644
1578 : index 000000000..8159b424a
1579 : --- /dev/null
1580 : +++ b/test/verify/files/file-chooser-test/foo
1581 : @@ -0,0 +1 @@
1582 : +A file of no consequence.
1583 : diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar
1584 : new file mode 100644
1585 : index 000000000..896416923
1586 : --- /dev/null
1587 : +++ b/test/verify/files/file-chooser-test/foobar
1588 : @@ -0,0 +1 @@
1589 : +Can't you think of any other names?
1590 : diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot
1591 : new file mode 100644
1592 : index 000000000..e69de29bb
1593 : diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots
1594 : new file mode 100644
1595 : index 000000000..e69de29bb
1596 : diff --git a/test/verify/files/file-chooser-test/text/bar.txt b/test/verify/files/file-chooser-test/text/bar.txt
1597 : new file mode 100644
1598 : index 000000000..ad41127d3
1599 : --- /dev/null
1600 : +++ b/test/verify/files/file-chooser-test/text/bar.txt
1601 : @@ -0,0 +1 @@
1602 : +No foo here.
1603 : diff --git a/test/verify/files/file-chooser-test/text/foo.txt b/test/verify/files/file-chooser-test/text/foo.txt
1604 : new file mode 100644
1605 : index 000000000..e6f4652aa
1606 : --- /dev/null
1607 : +++ b/test/verify/files/file-chooser-test/text/foo.txt
1608 : @@ -0,0 +1 @@
1609 : +foo is what I start with
|