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