diff --git a/pkg/lib/cockpit/react/FileChooser.css b/pkg/lib/cockpit/react/FileChooser.css new file mode 100644 index 000000000000..5de7dc50b1c7 --- /dev/null +++ b/pkg/lib/cockpit/react/FileChooser.css @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +.file-chooser-body { + display: grid; + grid-template-columns: minmax(15em, auto) 1fr; + grid-template-rows: auto auto 1fr; + column-gap: var(--pf-t--global--spacer--md); + row-gap: var(--pf-t--global--spacer--md); + block-size: 60ex; +} + +.file-chooser-sidebar { + grid-column: 1 / 2; + grid-row: 1 / 4; + overflow-y: scroll; + border-inline-end: solid 2px var(--pf-t--global--background--color--disabled--default); + padding-inline-end: var(--pf-t--global--spacer--md); +} + +.file-chooser-listing-header { + grid-column: 2 / 3; + grid-row: 1 / 2; +} + +.file-chooser-listing-header > div { + block-size: 100%; +} + +.file-chooser-listing-breadcrumbs { + grid-column: 2 / 3; + grid-row: 2 / 3; + /* align left of breadcrumb with left of table content */ + padding-inline-start: var(--pf-t--global--spacer--inset--page-chrome); +} + +.file-chooser-listing-body { + grid-column: 2 / 3; + grid-row: 3 / 4; + overflow-y: scroll; +} + +.file-chooser .pf-v6-c-alert { + margin-block-end: var(--pf-t--global--spacer--lg); +} + +@media (width < 768px) { + .file-chooser-body { + grid-template-columns: 0 1fr; + } + + .file-chooser-hide-on-narrow { + display: none; + } +} + +@media (width >= 768px) { + .file-chooser-hide-on-wide { + display: none; + } +} + +.pf-v6-c-table tr.file-chooser-selected:where(.pf-v6-c-table__tr) > :where(th, td) { + background: var(--pf-t--global--color--nonstatus--blue--default); + color: var(--pf-t--global--text--color--nonstatus--on-blue--default); +} + +/* Style the breadcrumb component as a path */ +.file-chooser-listing-breadcrumbs .pf-v6-c-breadcrumb__item-divider { + > svg { + display: none; + } + + &::after { + content: "/"; + } +} + +/* Size, align, and space icon correctly */ +.file-chooser-listing-breadcrumbs .breadcrumb-hdd-icon { + /* Set the size to a large icon */ + block-size: var(--pf-t--global--font--size--lg); + /* Width should resolve itself based on height and aspect ratio */ + inline-size: auto; + /* Align to the middle (as one would expect) */ + vertical-align: middle; +} diff --git a/pkg/lib/cockpit/react/FileChooser.tsx b/pkg/lib/cockpit/react/FileChooser.tsx new file mode 100644 index 000000000000..97af607d2277 --- /dev/null +++ b/pkg/lib/cockpit/react/FileChooser.tsx @@ -0,0 +1,982 @@ +/* + * Copyright (C) 2026 Red Hat, Inc. + * SPDX-License-Identifier: LGPL-2.1-or-later + */ + +/* This file exports two components + + - a FileChooser component that can be used with "Dialogs.show" to + show a configurable, general purpose file chooser dialog + + - a DialogFileChooserInput component that can be used with + "useDialogState" etc as a text input field for pathnames in + dialogs. + + A FileChooser is configured via these properties: + + - title: string + + The title in the header of the dialog. + + - filters?: undefined | FileChooserFilter[]; + + A list of "prepared filters". A filter looks like this: + + interface FileChooserFilter { + label: string; + filter: (name: string, type: string) => boolean, + } + + The "filter" function will be called with the base name of a file + and its type. The type is the string returned by "fsinfo", such as + "reg", "dir", "blk", etc. + + - shortcuts?: undefined | FileChooserShortcut[] | (() => Promise) + + A list of additional shortcuts to display in the sidebar of the + dialog. A shortcut looks like this: + + interface FileChooserShortcut { + label: string; + path: string; + } + + The path should point to a existing directory. + + Instead of a array of shortcuts, you can also pass a async function + that will return the array. The function will be called each time + when the dialog is opened. + + - collections?: undefined | FileChooserCollection[] | (() => Promise); + + A list of additional collections. A collection is a list of files + that are not necessarily in the same directory. The "Recent" entry + in the sidebar is a collection, for example. A collection looks like this: + + interface FileChooserCollection { + label: string; + emptyLabel: string; + list: () => Promise; + } + + The "list" function should return absolute pathnames. The + FileChooser will query their actual types and filter out any entry + that does not actually exist. The files will not be further + re-ordered before displaying them. If you want them to be sorted, + you need to do that before returning the array. + + - onlyDirectories?: undefined | boolean; + + If true, show only directories and let the user select a + directory. If false, directories are of course shown, but they + can't be selected. + + - superuser?: cockpit.SuperuserMode; + + The "superuser" option to use when listing files, etc. + + - recentKey?: undefined | string; + + A key for localStorage to retrieve the list of recent files. + Defaults to "recent-files". + + - actionLabel?: string; + + The label to put into the apply button of the file chooser. + Defaults to "Select". + + If you use the FileChooser by itself (and not via + DialogFileChooserInput), you can also specify the following + properties: + + - path: string; + + The initial path to open at. + + - action: (path: string) => Promise + + A function to run when the user clicks the apply button. When this + function throws an exception, the dialog does not close and the + error is shown in the dialog itself. + + The DialogFileChooserInput has the same properties as a + DialogTextInput plus this: + + - fileChooserProps + + The properties to use when opening the FileChooser dialog, such as + "title", "shortcuts", etc. + + */ + +import cockpit from "cockpit"; +import React, { useRef, useCallback, useEffect } from "react"; + +import { Modal, ModalBody, ModalHeader, ModalFooter } from '@patternfly/react-core/dist/esm/components/Modal'; +import { Table, Tbody, Tr, Td } from '@patternfly/react-table'; +import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex/index.js"; +import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb/index.js"; +import { EmptyState, EmptyStateActions, EmptyStateProps } from "@patternfly/react-core/dist/esm/components/EmptyState/index.js"; +import { Button } from '@patternfly/react-core/dist/esm/components/Button/index.js'; +import { Spinner } from '@patternfly/react-core/dist/esm/components/Spinner/index.js'; +import { FolderIcon, FolderOpenIcon, OutlinedHddIcon, SearchIcon } from '@patternfly/react-icons'; +import { + TextInputGroup, TextInputGroupMain, TextInputGroupUtilities +} from '@patternfly/react-core/dist/esm/components/TextInputGroup/index.js'; +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core/dist/esm/components/ToggleGroup/index.js'; +import { TextInput } from '@patternfly/react-core/dist/esm/components/TextInput/index.js'; +import { DropdownItem } from "@patternfly/react-core/dist/esm/components/Dropdown"; +import { Divider } from "@patternfly/react-core/dist/esm/components/Divider"; +import { Bullseye } from "@patternfly/react-core/dist/esm/layouts/Bullseye"; + +import { KebabDropdown } from "cockpit-components-dropdown"; + +import { useDialogs, WithDialogs } from 'dialogs'; +import { FsInfoClient, fsinfo } from "cockpit/fsinfo"; +import { basename, dirname } from "cockpit-path"; + +import { + useDialogState_async, + DialogState, + DialogField, + DialogErrorMessage, + DialogHelperText, + OptionalFormGroup, + DialogActionButton, +} from 'cockpit/dialog'; + +import "./FileChooser.css"; + +const _ = cockpit.gettext; + +async function getHomeDir(): Promise { + return (await cockpit.user()).home; +} + +async function getDownloadDir(): Promise { + try { + return (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim(); + } catch (ex) { + console.warn("Can't determine downloads directory", String(ex)); + return null; + } +} + +async function stdShortcuts(shortcuts: FileChooserShortcut[] = []): Promise { + const home = await getHomeDir(); + const dd = await getDownloadDir(); + + return [ + { label: _("Home"), path: home }, + ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []), + ...shortcuts, + ]; +} + +const OutlineFileIcon = () => { + return ( + + + + ); +}; + +function path_join(dir: string, base: string) { + return (dir == "/" ? "" : dir) + "/" + base; +} + +interface FileInfo { + type: string; + name: string; +} + +class FileError { + message: string; + + constructor(message: string) { + this.message = message; + } +} + +function watchFiles( + path: string, + onlyDirectories: boolean, + superuser: cockpit.SuperuserMode, + callback: (files: FileError | FileInfo[]) => void, +): FsInfoClient { + const client = new FsInfoClient( + path, + ["type", "entries", "target", "targets"], + { + follow: true, + ...(superuser ? { superuser } : { }) + } + ); + + client.on("close", message => { + if ("message" in message && typeof message.message == "string") + callback(new FileError(message.message)); + }); + + client.on("change", state => { + if (state.error) { + callback(new FileError(state.error.message)); + return; + } + + if (!state.info) + return; + + const info = state.info; + + if (!(info.type && info.entries && info.targets)) { + callback(new FileError(_("Permission denied"))); + return; + } + + if (info.type != "dir") { + callback(new FileError(_("Not a directory"))); + return; + } + + const result: FileInfo[] = []; + for (const name in info.entries) { + let entry = info.entries[name]; + if (entry.type == "lnk" && entry.target) + entry = info.entries[entry.target] || info.targets[entry.target]; + + if (entry && entry.type) { + if (!onlyDirectories || entry.type == "dir") + result.push({ type: entry.type, name }); + } + } + + function orderType(t: string) { + if (t == "dir") + return "a"; + else + return "b"; + } + + result.sort((a, b) => (orderType(a.type) + a.name).localeCompare(orderType(b.type) + b.name)); + callback(result); + }); + + return client; +} + +async function getFileInfos( + paths: string[], + onlyDirectories: boolean, + superuser: cockpit.SuperuserMode, +): Promise { + const res: FileInfo[] = []; + + for (const p of paths) { + try { + const info = await fsinfo(p, ["type"], superuser ? { superuser } : { }); + if (info.type && (!onlyDirectories || info.type == "dir")) + res.push({ name: p, type: info.type }); + } catch (ex) { + if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found")) + console.error("Failed to get file type:", p); + } + } + + return res; +} + +function readRecent(recentKey: string): string[] { + try { + const value = JSON.parse(window.localStorage.getItem(recentKey) || "[]"); + if (Array.isArray(value)) + return value.filter(r => typeof r == "string"); + } catch (ex) { + console.warn("Failed to parse recent files", String(ex)); + } + + return []; +} + +function boldify(name: string, filterText: string): React.ReactNode { + if (!filterText) + return name; + const parts: React.ReactNode[] = []; + let pos; + let key = 0; + while ((pos = name.indexOf(filterText)) >= 0) { + parts.push(name.substring(0, pos)); + parts.push({name.substring(pos, pos + filterText.length)}); + name = name.substring(pos + filterText.length); + } + if (name) + parts.push(name); + return parts; +} + +export interface FileChooserFilter { + label: string; + filter: (name: string, type: string) => boolean, +} + +export interface FileChooserShortcut { + label: string; + path: string; +} + +export interface FileChooserCollection { + label: string; + emptyLabel: string; + list: () => Promise; +} + +export interface FileChooserProps { + title: string; + shortcuts?: undefined | FileChooserShortcut[] | (() => Promise); + filters?: undefined | FileChooserFilter[]; + collections?: undefined | FileChooserCollection[] | (() => Promise); + onlyDirectories?: undefined | boolean; + superuser?: cockpit.SuperuserMode; + recentKey?: undefined | string; + actionLabel?: string; +} + +interface FileChooserValues { + path: string; + collection: null | FileChooserCollection; + files: null | FileError | FileInfo[]; + selected: null | FileInfo; + textFilter: string; + filters: FileChooserFilter[]; + filter: FileChooserFilter; + recent_collection: FileChooserCollection; + shortcuts: FileChooserShortcut[]; + collections: FileChooserCollection[]; + showHidden: boolean; +} + +export const FileChooser = ({ + title, + shortcuts = [], + filters = [], + collections = [], + onlyDirectories = false, + superuser, + recentKey = "recent-files", + actionLabel, + path = "", + action, +} : { + path?: string, + action: (path: string) => Promise, +} & FileChooserProps) => { + const Dialogs = useDialogs(); + const textInputRef = useRef(null); + const fsInfoClientRef = useRef(null); + + function focusFilter() { + textInputRef.current?.focus(); + } + + useEffect(() => { + textInputRef.current?.focus(); + }, []); + + async function init(): Promise { + const all_filters = filters.concat([{ label: _("All files"), filter: _n => true }]); + + const recent_collection = { + label: _("Recent"), + emptyLabel: onlyDirectories ? _("No recent directories") : _("No recent files"), + list: async () => readRecent(recentKey) + }; + + const shortcuts_list = Array.isArray(shortcuts) ? shortcuts : await shortcuts(); + const collections_list = Array.isArray(collections) ? collections : await collections(); + + return { + path, + collection: path == "" ? recent_collection : null, + files: null, + selected: null, + textFilter: "", + filters: all_filters, + filter: all_filters[0], + recent_collection, + shortcuts: await stdShortcuts(shortcuts_list), + collections: collections_list, + showHidden: false, + }; + } + + const dlg = useDialogState_async(init); + + const setPath = useCallback( + (dlg: DialogState, path: string) => { + dlg.field("path").set(path); + dlg.field("collection").set(null); + dlg.field("selected").set(null); + dlg.field("files").set(null); + + if (fsInfoClientRef.current) + fsInfoClientRef.current.close(); + + fsInfoClientRef.current = watchFiles( + path, + onlyDirectories, + superuser, + files => { + dlg.field("files").set(files); + } + ); + }, + [onlyDirectories, superuser], + ); + + const setCollection = useCallback( + (dlg: DialogState, collection: FileChooserCollection) => { + dlg.field("path").set(""); + dlg.field("collection").set(collection); + dlg.field("selected").set(null); + dlg.field("files").set(null); + + if (fsInfoClientRef.current) + fsInfoClientRef.current.close(); + + fsInfoClientRef.current = null; + dlg.field("files").set_async(async () => await getFileInfos(await collection.list(), onlyDirectories, superuser)); + }, + [onlyDirectories, superuser], + ); + + useEffect(() => { + if (dlg instanceof DialogState) { + if (dlg.values.collection) + setCollection(dlg, dlg.values.collection); + else + setPath(dlg, dlg.values.path); + } + return () => { + if (fsInfoClientRef.current) + fsInfoClientRef.current.close(); + }; + }, [dlg, setPath, setCollection]); + + function full_path(path: string, selected: string) { + if (path == "") + return selected; + else + return path_join(path, selected); + } + + function selected_path(): string | null { + if (!(dlg instanceof DialogState)) + return null; + + const { selected, path } = dlg.values; + + if (onlyDirectories) { + if (!selected && path != "") + return path; + else if (selected && selected.type == "dir") + return full_path(path, selected.name); + } else { + if (selected && selected.type != "dir") + return full_path(path, selected.name); + } + + return null; + } + + async function onAction() { + const full = selected_path(); + cockpit.assert(full); + rememberRecent(full, recentKey); + await action(full); + } + + function breadcrumbs(dlg: DialogState) { + const { path } = dlg.values; + + if (path == "") { + // Collection + return null; + } else { + const dirs = ["/"].concat(path.split("/").filter(d => !!d)); + const crumbs: React.ReactNode[] = []; + let full = "/"; + dirs.forEach((d, i) => { + if (d != "/") + full = path_join(full, d); + const path = full; + crumbs.push( + { + setPath(dlg, path); + event.preventDefault(); + } + } + isActive={i == dirs.length - 1} + > + { d == "/" ? : d } + + ); + }); + + return ( + + {crumbs} + + ); + } + } + + function header(dlg: DialogState) { + const preparedFilters = ( + dlg.values.filters.length > 1 && + + { + dlg.values.filters.map(f => { + return ( + { + dlg.field("filter").set(f); + focusFilter(); + }} + text={f.label} + /> + ); + }) + } + + ); + + const textFilter = ( + dlg.field("textFilter").set(value)} + /> + ); + + function shortcut(sc: FileChooserShortcut) { + return ( + setPath(dlg, sc.path)} + className="file-chooser-hide-on-wide" + > + {sc.label} + + ); + } + + function collection(cl: FileChooserCollection) { + return ( + setCollection(dlg, cl)} + className="file-chooser-hide-on-wide" + > + {cl.label} + + ); + } + + return ( + + + {textFilter} + + + {preparedFilters} + + + { + cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path })); + } + } + isDisabled={dlg.values.path === ""} + > + {_("Open in file browser")} + , + { + dlg.field("showHidden").set(!dlg.values.showHidden); + } + } + > + {dlg.values.showHidden ? _("Hide hidden files") : _("Show hidden files")} + , + , + collection(dlg.values.recent_collection), + ...dlg.values.shortcuts.map(shortcut), + shortcut({ label: _("Filesystem"), path: "/" }), + ...dlg.values.collections.map(collection) + ] + } + /> + + + ); + } + + function formatIcon(f: FileInfo): React.ReactNode { + if (f.type == "dir") + return ; + else + return ; + } + + function sidebar(dlg: DialogState) { + function shortcut(sc: FileChooserShortcut) { + return ( + { + setPath(dlg, sc.path); + focusFilter(); + } + } + > + {sc.label} + + ); + } + + function collection(col: FileChooserCollection) { + return ( + { + setCollection(dlg, col); + focusFilter(); + } + } + > + {col.label} + + ); + } + + return ( + + + { collection(dlg.values.recent_collection) } + { dlg.values.shortcuts.map(shortcut) } + { shortcut({ label: _("Filesystem"), path: "/" }) } + { dlg.values.collections.map(collection) } + +
+ ); + } + + function listing(dlg: DialogState) { + function emptyState(content: string, icon: NonNullable, clearFilters: number = 0) { + return ( + + + + + + { (clearFilters > 0) && + + + + } + + + + + + ); + } + + function listingBody() { + const files = dlg.values.files; + + if (files == null) + return emptyState("", Spinner); + + if (files instanceof FileError) + return emptyState(files.message, FolderIcon); + + if (files.length == 0) { + if (dlg.values.collection) { + return emptyState(dlg.values.collection.emptyLabel, FolderIcon); + } else if (!onlyDirectories) { + return emptyState(_("Directory is empty"), FolderIcon); + } else { + return emptyState(_("Directory has no sub-directories"), FolderIcon); + } + } + + const withoutHidden = dlg.values.showHidden ? files : files.filter(f => basename(f.name)[0] !== "."); + if (withoutHidden.length == 0) + return emptyState(_("This directory contains only hidden files"), SearchIcon, 3); + + const preFiltered = withoutHidden.filter( + f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(basename(f.name), f.type) + ); + if (preFiltered.length == 0) + return emptyState(_("No matching results"), SearchIcon, 2); + + const filtered = preFiltered.filter(f => basename(f.name).includes(dlg.values.textFilter)); + if (filtered.length == 0) + return emptyState(_("No matching results"), SearchIcon, 1); + + return ( + + { + filtered.map( + (f, idx) => { + let name, location; + if (dlg.values.path == "") { + name = basename(f.name); + location = dirname(f.name); + } else { + name = f.name; + } + return ( + { + dlg.field("selected").set(f); + focusFilter(); + } + } + onDoubleClick={ + event => { + event.preventDefault(); + if (f.type == "dir") { + setPath(dlg, full_path(dlg.values.path, f.name)); + dlg.field("textFilter").set(""); + } + focusFilter(); + } + } + isClickable + > + + {formatIcon(f)} +    + {boldify(name, dlg.values.textFilter)} + + { location && {location} } + + ); + } + ) + } + + ); + } + + return ( + + { listingBody() } +
+ ); + } + + return ( + + + + +
+
+ { + dlg instanceof DialogState + ? sidebar(dlg) + : + } +
+
+ { dlg instanceof DialogState && header(dlg) } +
+
+ { dlg instanceof DialogState && breadcrumbs(dlg) } +
+
+ { dlg instanceof DialogState && listing(dlg) } +
+
+
+ + + {actionLabel || _("Select")} + + +
+ ); +}; + +const FileChooserButton = ({ + value, + onChoose, + props, +} : { + value: string, + onChoose: (path: string) => void, + props: FileChooserProps, +}) => { + const Dialogs = useDialogs(); + + return ( + + ); +}; + const Demo = () => { return ( @@ -702,6 +825,7 @@ const Demo = () => { + diff --git a/test/common/dialoglib.py b/test/common/dialoglib.py index d9f040327ad3..b2f5981c7247 100644 --- a/test/common/dialoglib.py +++ b/test/common/dialoglib.py @@ -148,3 +148,14 @@ def wait_DropdownSelect(self, path: str, val: str) -> None: def set_DropdownSelect(self, path: str, val: str) -> None: self.browser.select_from_dropdown(self.field(path), val) + + # FileChooserInput + + def get_FileChooserInput(self, path: str) -> str: + return self.browser.val(self.field(path) + " input") + + def wait_FileChooserInput(self, path: str, val: str): + self.browser.wait_val(self.field(path) + " input", val) + + def set_FileChooserInput(self, path: str, val: str) -> None: + self.browser.set_input_text(self.field(path) + " input", val) diff --git a/test/verify/check-dialog b/test/verify/check-dialog index f614daa33b1e..d04db10d6c72 100755 --- a/test/verify/check-dialog +++ b/test/verify/check-dialog @@ -350,6 +350,283 @@ class TestDialog(testlib.MachineCase): b.click(d.cancel_button()) b.wait_not_present("#dialog") + def testFileChooser(self): + b = self.browser + m = self.machine + d = dialoglib.DialogHelpers(b, "#dialog") + df = dialoglib.DialogHelpers(b, ".file-chooser") + + # Inject a mock xdg-user-dir utility. + + self.write_file("/usr/local/bin/xdg-user-dir", +"""#! /bin/sh +echo $HOME/Downloads +""", perm="a+x") + + # Where our test files are. This is intended to be the same as + # self.vm_tmpdir, but it is also hard-coded into + # pkg/playground/dialog.tsx and so we hard-code it here as + # well. + + test_files = "/var/lib/cockpittest" + + self.login_and_go("/playground/dialog", superuser=False) + + b.click("#open") + + # Use the get_FileChooserInput method so that Vulture doesn't + # complain about it being unused. + + self.assertEqual(d.get_FileChooserInput("file"), "") + + # The first open has a empty Recent tab. + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.wait_in_text(".file-chooser-listing-body", "No recent files") + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + + # Basic interaction with the text input + + d.set_FileChooserInput("file", "/home/non-existent/foo") + b.wait_in_text(d.helper_text("file"), "(No such file or directory)") + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.wait_in_text(".file-chooser-listing-body", "No such file or directory") + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + + m.upload(["verify/files/file-chooser-test/"], test_files) + m.execute(f"mkdir '{test_files}/file-chooser-test/empty'") + d.set_FileChooserInput("file", test_files) + b.wait_in_text(d.helper_text("file"), "directory") + + def file(name): + return f".file-chooser-listing-body tr[data-name='{name}']" + + # Navigate to empty directory + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.wait_visible(".file-chooser") + b.mouse(file("cockpittest"), "dblclick") + b.mouse(file("file-chooser-test"), "dblclick") + b.mouse(file("empty"), "dblclick") + b.wait_in_text(".file-chooser-listing-body", "Directory is empty") + + # Go up and choose tmpdir/file-chooser-test/foo + + b.click(".file-chooser-listing-breadcrumbs a:contains('file-chooser-test')") + b.assert_pixels(".file-chooser", "basic") + b.mouse(file("foo"), "click") + b.click(df.apply_button()) + + d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo") + b.wait_in_text(d.helper_text("file"), "ASCII text") + + # "foo" should now be in "Recent" + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.wait_visible(".file-chooser-listing-breadcrumbs nav") + b.wait_visible(file("foo")) + b.click(".file-chooser-sidebar tr:contains('Recent')") + b.wait_not_present(".file-chooser-listing-breadcrumbs nav") + b.wait_visible(file("foo")) + b.wait_in_text(file("foo"), test_files + "/file-chooser-test") + b.mouse(file("foo"), "click") + b.click(df.apply_button()) + d.wait_FileChooserInput("file", test_files + "/file-chooser-test/foo") + b.wait_in_text(d.helper_text("file"), "ASCII text") + + # Check that "Home" has some expected files + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.click(".file-chooser-sidebar tr:contains('Home')") + # home can be /home/admin or /var/home/admin. + b.wait_in_text(".file-chooser-listing-breadcrumbs", "homeadmin") + b.click(".file-chooser-listing-breadcrumbs a:contains('home')") + b.mouse(file("admin"), "dblclick") + b.wait_in_text(".file-chooser-listing-body", "This directory contains only hidden files") + b.click(".file-chooser-listing-body button:contains('Show hidden files')") + b.mouse(file(".ssh"), "dblclick") + b.mouse(file("authorized_keys"), "click") + b.click(df.apply_button()) + + d.wait_FileChooserInput("file", "/home/admin/.ssh/authorized_keys") + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key") + + # Check the "Downloads" shortcut + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.click(".file-chooser-sidebar tr:contains('Downloads')") + b.wait_text(".file-chooser-listing-breadcrumbs", "homeadminDownloads") + b.wait_in_text(".file-chooser-listing-body", "No such file or directory") + + # Check that we can't read /root + + b.click(".file-chooser-sidebar tr:contains('Filesystem')") + b.mouse(file("root"), "dblclick") + b.wait_in_text(".file-chooser-listing-body", "Permission denied") + b.assert_pixels(".file-chooser", "denied") + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + + # Free text filtering + + d.set_FileChooserInput("file", "") + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.click(".file-chooser-sidebar tr:contains('Test files')") + b.mouse(file("file-chooser-test"), "dblclick") + + b.wait_visible(file("bar")) + b.wait_visible(file("foo")) + b.wait_visible(file("foobar")) + + b.set_input_text(".file-chooser-listing-header input", "fo") + b.wait_visible(file("foo")) + b.wait_not_present(file("bar")) + b.wait_visible(file("foobar")) + b.assert_pixels(".file-chooser", "filtered") + + b.set_input_text(".file-chooser-listing-header input", "ba") + b.wait_not_present(file("foo")) + b.wait_visible(file("bar")) + b.wait_visible(file("foobar")) + + b.set_input_text(".file-chooser-listing-header input", "xxx") + b.wait_in_text(".file-chooser-listing-body", "No matching results") + b.click(".file-chooser-listing-body button:contains('Clear filters')") + + b.wait_visible(file("bar")) + b.wait_visible(file("foo")) + b.wait_visible(file("foobar")) + + # Prepared filtering. + + # "No dots" was already active all the time, switch it off to + # reveal more files. + + b.click(".file-chooser-listing-header button:contains('All files')") + + b.wait_visible(file("bar")) + b.wait_visible(file("foo")) + b.wait_visible(file("foobar")) + b.wait_visible(file("dots.txt")) + b.wait_visible(file("only.dots")) + + b.mouse(file("only.dots"), "dblclick") + b.wait_visible(file("one.dot")) + b.wait_visible(file("two.dots")) + + b.click(".file-chooser-listing-header button:contains('No dots')") + + b.wait_in_text(".file-chooser-listing-body", "No matching results") + + # Filter even more, this should get cleared as well + b.set_input_text(".file-chooser-listing-header input", "x") + + b.click(".file-chooser-listing-body button:contains('Clear filters')") + + b.wait_visible(file("one.dot")) + b.wait_visible(file("two.dots")) + + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + + # Test the collection + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.click(".file-chooser-sidebar tr:contains('Some files')") + b.wait_visible(file("foo")) + b.wait_not_present(file("dots.txt")) + b.click(".file-chooser-listing-header button:contains('All files')") + b.wait_visible(file("foo")) + b.wait_visible(file("dots.txt")) + b.click(file("dots.txt")) + b.click(df.apply_button()) + + d.wait_FileChooserInput("file", "/var/lib/cockpittest/file-chooser-test/dots.txt") + b.wait_in_text(d.helper_text("file"), "ASCII text") + + # Become superuser and access /root/.ssh + + b.become_superuser() + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.click(".file-chooser-sidebar tr:contains('Filesystem')") + b.select_PF(".file-chooser-kebab", "Show hidden files") + b.mouse(file("root"), "dblclick") + b.mouse(file(".ssh"), "dblclick") + b.mouse(file("authorized_keys"), "click") + b.click(df.apply_button()) + d.wait_FileChooserInput("file", "/root/.ssh/authorized_keys") + b.wait_in_text(d.helper_text("file"), "OpenSSH RSA public key") + + # Select a directory + + d.wait_FileChooserInput("dir", "") + b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button") + + b.wait_in_text(".file-chooser-listing-body", "No recent directories") + b.click(".file-chooser-sidebar tr:contains('Test files')") + b.click(file("file-chooser-test")) + b.click(df.apply_button()) + + d.wait_FileChooserInput("dir", test_files + "/file-chooser-test") + + b.click(d.field("dir") + " .pf-v6-c-text-input-group__utilities button") + b.wait_visible(file("only.dots")) + b.click(".file-chooser-sidebar tr:contains('Recent')") + b.wait_visible(file("file-chooser-test")) + b.wait_not_present(file("foo")) + b.wait_in_text(file("file-chooser-test"), test_files) + + b.wait_visible(df.apply_button() + "[aria-disabled=true]") + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + + # Check mobile layout + + b.set_layout("mobile") + + b.click(d.field("file") + " .pf-v6-c-text-input-group__utilities button") + b.wait_not_visible(".file-chooser-sidebar") + b.wait_visible(".file-chooser-kebab") + + b.select_PF(".file-chooser-kebab", "Test files") + b.mouse(file("file-chooser-test"), "dblclick") + b.wait_visible(file("empty")) + + b.click(".file-chooser .pf-v6-c-modal-box__close button") + b.wait_not_present(".file-chooser") + b.click(d.cancel_button()) + b.set_layout("desktop") + + # Stand-alone File Chooser + + b.click("#open-file-chooser") + b.wait_visible(".file-chooser") + b.click(".file-chooser-sidebar tr:contains('Some TXT files')") + b.wait_visible(file("foo.txt")) + b.wait_not_present(file("no-such-file.txt")) + b.click(file("foo.txt")) + b.wait_text(df.apply_button(), "Load") + b.click(df.apply_button()) + b.wait_not_present(".file-chooser") + + b.click("#open-file-chooser") + b.wait_visible(".file-chooser") + b.click(".file-chooser-sidebar tr:contains('Test files')") + b.mouse(file("file-chooser-test"), "dblclick") + b.mouse(file("text"), "dblclick") + b.wait_visible(file("foo.txt")) + b.wait_visible(file("bar.txt")) + b.click(file("bar.txt")) + b.click(df.apply_button()) + b.wait_in_text(df.error(), "Does not start with \"foo\"") + b.click(file("foo.txt")) + b.click(df.apply_button()) + b.wait_not_present(".file-chooser") + if __name__ == '__main__': testlib.test_main() diff --git a/test/verify/files/file-chooser-test/bar b/test/verify/files/file-chooser-test/bar new file mode 100644 index 000000000000..de345c34175b --- /dev/null +++ b/test/verify/files/file-chooser-test/bar @@ -0,0 +1 @@ +Nothing to see. diff --git a/test/verify/files/file-chooser-test/dots.txt b/test/verify/files/file-chooser-test/dots.txt new file mode 100644 index 000000000000..0aadcf89b1f1 --- /dev/null +++ b/test/verify/files/file-chooser-test/dots.txt @@ -0,0 +1 @@ +A file with a dot in its name. diff --git a/test/verify/files/file-chooser-test/foo b/test/verify/files/file-chooser-test/foo new file mode 100644 index 000000000000..8159b424a6d0 --- /dev/null +++ b/test/verify/files/file-chooser-test/foo @@ -0,0 +1 @@ +A file of no consequence. diff --git a/test/verify/files/file-chooser-test/foobar b/test/verify/files/file-chooser-test/foobar new file mode 100644 index 000000000000..896416923e87 --- /dev/null +++ b/test/verify/files/file-chooser-test/foobar @@ -0,0 +1 @@ +Can't you think of any other names? diff --git a/test/verify/files/file-chooser-test/only.dots/one.dot b/test/verify/files/file-chooser-test/only.dots/one.dot new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/verify/files/file-chooser-test/only.dots/two.dots b/test/verify/files/file-chooser-test/only.dots/two.dots new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/test/verify/files/file-chooser-test/text/bar.txt b/test/verify/files/file-chooser-test/text/bar.txt new file mode 100644 index 000000000000..ad41127d3754 --- /dev/null +++ b/test/verify/files/file-chooser-test/text/bar.txt @@ -0,0 +1 @@ +No foo here. diff --git a/test/verify/files/file-chooser-test/text/foo.txt b/test/verify/files/file-chooser-test/text/foo.txt new file mode 100644 index 000000000000..e6f4652aa65e --- /dev/null +++ b/test/verify/files/file-chooser-test/text/foo.txt @@ -0,0 +1 @@ +foo is what I start with