/* * 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. (Implies allowDirectories.) - allowDirectories?: undefined | boolean; If true, let the user select a directory in addition to files. - superuser?: cockpit.SuperuserMode; The "superuser" option to use when listing files, etc. - 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 { Icon } from "@patternfly/react-core/dist/esm/components/Icon"; import { KebabDropdown } from "cockpit-components-dropdown"; import { useDialogs, WithDialogs } from 'dialogs'; import { FsInfoClient, fsinfo } from "cockpit/fsinfo"; import { basename, dirname, path_join } from "cockpit-path"; import { useDialogState_async, DialogState, DialogField, DialogErrorMessage, DialogHelperText, OptionalFormGroup, DialogActionButton, } from 'cockpit/dialog'; import "./FileChooser.css"; const _ = cockpit.gettext; const FILE_CHOOSER_RECENT_KEY = "FileChooser:recent-files"; async function getHomeDir(): Promise { return (await cockpit.user()).home; } let downloadDir: false | string | null = false; async function getDownloadDir(): Promise { if (downloadDir !== false) return downloadDir; try { downloadDir = (await cockpit.spawn(["xdg-user-dir", "DOWNLOAD"], { err: "message" })).trim(); } catch (ex) { console.warn("Can't determine downloads directory", String(ex)); downloadDir = null; } return downloadDir; } 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 ( ); }; interface FileInfo { type: string; name: string; } class FileError extends Error { } 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(): string[] { try { const value = JSON.parse(window.localStorage.getItem(FILE_CHOOSER_RECENT_KEY) || "[]"); if (Array.isArray(value)) return value.filter(r => typeof r === "string"); } catch (ex) { console.warn("Failed to parse recent files", String(ex)); } return []; } function emphasize_matches(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; allowDirectories?: undefined | boolean; superuser?: cockpit.SuperuserMode; 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; } const HideOnWide = ({ children } : { children: React.ReactNode }) => { return (
{children}
); }; export const FileChooser = ({ title, shortcuts = [], filters = [], collections = [], onlyDirectories = false, allowDirectories = false, superuser, actionLabel, path = "", action, } : { path?: string, action: (path: string) => Promise, } & FileChooserProps) => { const Dialogs = useDialogs(); const fsInfoClientRef = useRef(null); 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() }; 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 (allowDirectories || onlyDirectories) { if (!selected && path !== "") return path; else if (selected && (!onlyDirectories || 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); await action(full); } function breadcrumbs(dlg: DialogState) { const { path, collection } = dlg.values; if (collection) { // No breadcrumbs for collections. return null; } cockpit.assert(path !== ""); const dirs = ["/"].concat(path.split("/").filter(d => !!d)); const crumbs: React.ReactNode[] = []; let full = "/"; dirs.forEach((dir, i) => { if (dir !== "/") full = path_join(full, dir); const path = full; crumbs.push( { setPath(dlg, path); event.preventDefault(); } } isActive={i === dirs.length - 1} > { dir === "/" ? : dir } ); }); return ( {crumbs} ); } function header(dlg: DialogState) { const preparedFilters = ( dlg.values.filters.length > 1 && { dlg.values.filters.map(f => { return ( { dlg.field("filter").set(f); }} text={f.label} /> ); }) } ); const textFilter = ( dlg.field("textFilter").set(value)} /> ); function shortcut(sc: FileChooserShortcut) { return ( setPath(dlg, sc.path)} > {sc.label} ); } function collection(cl: FileChooserCollection) { return ( setCollection(dlg, cl)} > {cl.label} ); } function cockpit_files_installed(w: Window) { try { if ("cockpit" in w) { const c = w.cockpit as typeof cockpit; return !!c.manifests.files; } else return false; } catch (ex) { console.log("Can't access manifests", String(ex)); return false; } } return ( {textFilter} {preparedFilters} { cockpit.jump("files#" + cockpit.location.encode([], { path: dlg.values.path })); } } isDisabled={dlg.values.path === ""} > {_("Open in file browser")} , { /* Delay the state change a tiny bit so that it doesn't happen while the menu is still open. */ window.setTimeout( () => dlg.field("showHidden").set(!dlg.values.showHidden), 5 ); } } > {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 keyboardNavigationHandler(event: React.KeyboardEvent) { if (event.key === "ArrowDown") { const tr = event.target; if (tr instanceof HTMLElement && tr.nextElementSibling instanceof HTMLElement) tr.nextElementSibling.focus(); event.preventDefault(); } else if (event.key === "ArrowUp") { const tr = event.target; if (tr instanceof HTMLElement && tr.previousElementSibling instanceof HTMLElement) tr.previousElementSibling.focus(); event.preventDefault(); } } function sidebar(dlg: DialogState) { function shortcut(sc: FileChooserShortcut) { return ( { setPath(dlg, sc.path); } } > {sc.label} ); } function collection(col: FileChooserCollection) { return ( { setCollection(dlg, col); } } > {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 filteredFiles = preFiltered.filter(file => basename(file.name).includes(dlg.values.textFilter)); if (filteredFiles.length === 0) return emptyState(_("No matching results"), SearchIcon, 1); function rowClick(event: React.KeyboardEvent | React.MouseEvent | undefined, file: FileInfo) { dlg.field("selected").set(file); if (event && (event.type === "dblclick" || (event.type === "keydown" && "key" in event && event.key === "Enter"))) { if (file.type === "dir") { setPath(dlg, full_path(dlg.values.path, file.name)); dlg.field("textFilter").set(""); } } } return ( { filteredFiles.map( (file, idx) => { let name, location; if (dlg.values.path === "") { name = basename(file.name); location = dirname(file.name); } else { name = file.name; } return ( rowClick(event, file)} onDoubleClick={event => rowClick(event, file)} isClickable > { file.type === "dir" ? : }    {emphasize_matches(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 (