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