From 0f3f9e5136bdfc396f5330d2164016fac25de73a Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 04:43:48 -0300 Subject: [PATCH 01/14] feat: add file entry icon helper with support for various file types Introduced a new helper component for rendering file entry icons based on file extensions. This includes support for image, audio, video, archive, disc, script, save, patch, and code file types, enhancing the user interface for file management. --- .../src/helpers/file-entry-icon.tsx | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 src/big-picture/src/helpers/file-entry-icon.tsx diff --git a/src/big-picture/src/helpers/file-entry-icon.tsx b/src/big-picture/src/helpers/file-entry-icon.tsx new file mode 100644 index 000000000..ba3fb0762 --- /dev/null +++ b/src/big-picture/src/helpers/file-entry-icon.tsx @@ -0,0 +1,581 @@ +import type { ReactNode } from "react"; +import { + AppleLogoIcon, + DiscIcon, + FileArchiveIcon, + HeadphonesIcon, + FileCIcon, + FileCodeIcon, + FileCppIcon, + FileCSharpIcon, + FileCssIcon, + FileCsvIcon, + FileDocIcon, + FileHtmlIcon, + FileIcon, + ImageIcon, + FileIniIcon, + FileJsIcon, + FileJsxIcon, + FileLockIcon, + FileMdIcon, + FilePyIcon, + FileSqlIcon, + FileSvgIcon, + FileTsIcon, + FileTsxIcon, + FileTxtIcon, + VideoCameraIcon, + FloppyDiskIcon, + FolderIcon, + LinuxLogoIcon, + TerminalWindowIcon, + WindowsLogoIcon, + FilePdfIcon, +} from "@phosphor-icons/react"; + +export interface DirectoryEntry { + name: string; + path: string; + isDirectory: boolean; + isFile: boolean; + extension: string; + size: number; + fileCount: number; +} + +const iconProps = { + size: 22, + weight: "fill", +} as const; + +export const imageExtensions = new Set([ + "jpg", + "jpeg", + "jpe", + "jfif", + "pjpeg", + "pjp", + "png", + "apng", + "gif", + "webp", + "bmp", + "dib", + "ico", + "cur", + "tif", + "tiff", + "avif", + "heic", + "heif", + "jxl", + "psd", + "xcf", + "raw", + "dng", + "cr2", + "cr3", + "nef", + "arw", + "orf", + "rw2", + "raf", + "pef", + "srw", +]); + +export const audioExtensions = new Set([ + "mp3", + "mp2", + "mpa", + "wav", + "wave", + "ogg", + "oga", + "opus", + "flac", + "m4a", + "aac", + "adts", + "wma", + "aiff", + "aif", + "aifc", + "ape", + "alac", + "amr", + "mka", + "weba", + "mid", + "midi", + "kar", + "mod", + "xm", + "it", + "s3m", + "umx", + "ac3", + "dts", + "mka", + "tta", + "wv", + "ra", +]); + +export const videoExtensions = new Set([ + "mp4", + "m4v", + "avi", + "mkv", + "mov", + "qt", + "wmv", + "webm", + "mpeg", + "mpg", + "mpe", + "mpv", + "m2v", + "flv", + "f4v", + "3gp", + "3g2", + "ogv", + "m2ts", + "mts", + "vob", + "asf", + "rm", + "rmvb", + "divx", +]); + +export const archiveExtensions = new Set([ + "zip", + "rar", + "7z", + "tar", + "gz", + "tgz", + "xz", + "txz", + "bz", + "bz2", + "tbz", + "tbz2", + "z", + "zst", + "tzst", + "lz", + "lzma", + "lz4", + "cab", + "ar", + "cpio", + "apk", + "xapk", + "apks", + "aab", + "jar", + "war", + "ear", + "whl", + "egg", + "nupkg", + "vsix", + "gem", +]); + +export const discExtensions = new Set([ + "iso", + "bin", + "cue", + "img", + "ccd", + "sub", + "mdf", + "mds", + "nrg", + "cdi", + "gdi", + "chd", + "ecm", + "pbp", + "cso", + + "nes", + "fds", + "unf", + "unif", + + "sfc", + "smc", + "fig", + "swc", + + "gb", + "gbc", + "gba", + + "n64", + "z64", + "v64", + + "nds", + "dsi", + "3ds", + "cia", + "cxi", + "cci", + + "gcm", + "rvz", + "wbfs", + "wad", + "wia", + "wua", + "wud", + "wux", + "gcz", + "dol", + + "nsp", + "xci", + "nro", + "nso", + "nca", + "ncz", + "xcz", + + "sms", + "gg", + "sg", + "smd", + "gen", + "32x", + "68k", + + "pce", + "sgx", + + "a26", + "a52", + "a78", + "j64", + "lnx", + + "st", + "msa", + "stx", + + "ws", + "wsc", + + "ngp", + "ngc", + + "col", + "int", + "vec", + + "tap", + "tzx", + "dsk", + "adf", + "ipf", + + "elf", + "prx", + "pkg", + "vpk", + "3dsx", +]); + +export const scriptExtensions = new Set([ + "sh", + "bash", + "zsh", + "fish", + "ksh", + "csh", + "tcsh", + "cmd", + "bat", + "ps1", + "psm1", + "psd1", + "vbs", + "vbe", + "wsf", + "wsh", + "command", + "run", +]); + +export const saveExtensions = new Set([ + "mcr", + "mcd", + "mc", + "mci", + "psu", + "ps2", + "max", + "cbs", + "xps", + "sps", + "psv", + + "vmem", + "srm", + "sav", + "save", + "state", + "sgm", + "dsv", + "dst", + "gci", + "vmu", + "dci", + "duc", + + "eep", + "eeprom", + "fla", + "flash", + "nv", + "nvm", + "sa1", + "fra", + "fs", +]); + +export const patchExtensions = new Set([ + "ips", + "bps", + "ups", + "xdelta", + "xdelta3", + "vcdiff", + "ppf", + "aps", +]); + +export const codeExtensions = new Set([ + "go", + "rs", + "rb", + "php", + "java", + "swift", + "kt", + "kts", + "scala", + "pl", + "pm", + "lua", + "r", + "dart", + "groovy", + "clj", + "cljs", + "elm", + "ex", + "exs", + "erl", + "hrl", + "hs", + "ml", + "mli", + "nim", + "cr", + "zig", + "odin", + "v", + "wgsl", + "vue", + "svelte", + "astro", + "gradle", + "toml", + "yaml", + "yml", + "json", + "jsonc", + "json5", + "xml", + "xaml", + "proto", + "graphql", + "gql", + "sol", + "tf", + "tfvars", + "hcl", + "ipynb", +]); + +const extensionIcons: Record = { + c: , + h: , + + cs: , + + cpp: , + cxx: , + cc: , + "c++": , + hpp: , + hxx: , + hh: , + + css: , + scss: , + less: , + sass: , + + csv: , + tsv: , + xls: , + xlsx: , + xlsm: , + ods: , + + doc: , + docx: , + gdoc: , + odt: , + rtf: , + tex: , + ppt: , + pptx: , + odp: , + epub: , + mobi: , + azw: , + azw3: , + + html: , + htm: , + xhtml: , + + ini: , + cfg: , + conf: , + config: , + properties: , + + js: , + mjs: , + cjs: , + + jsx: , + + lock: , + env: , + pem: , + key: , + crt: , + cer: , + pfx: , + p12: , + asc: , + sig: , + + md: , + markdown: , + mdx: , + rst: , + + py: , + pyw: , + + sql: , + sqlite: , + sqlite3: , + db: , + + svg: , + + ts: , + tsx: , + + txt: , + text: , + log: , + nfo: , + + exe: , + msi: , + msp: , + msu: , + appx: , + appxbundle: , + msix: , + msixbundle: , + appinstaller: , + com: , + scr: , + dll: , + sys: , + + appimage: , + deb: , + udeb: , + rpm: , + flatpak: , + flatpakref: , + flatpakrepo: , + snap: , + desktop: , + + dmg: , + app: , + ipa: , + xip: , + mpkg: , + + pdf: , +}; + +const groupedExtensionIcons: Array<[Set, ReactNode]> = [ + [imageExtensions, ], + [audioExtensions, ], + [videoExtensions, ], + [archiveExtensions, ], + [discExtensions, ], + [scriptExtensions, ], + [saveExtensions, ], + [patchExtensions, ], + [codeExtensions, ], +]; + +function normalizeExtension(extension: string): string { + return extension.replace(/^\./, "").toLowerCase(); +} + +function getIconByExtension(extension: string): ReactNode { + const directIcon = extensionIcons[extension]; + + if (directIcon) return directIcon; + + const groupedIcon = groupedExtensionIcons.find(([extensions]) => + extensions.has(extension) + ); + + return groupedIcon?.[1] ?? ; +} + +export function getEntryIcon(entry: DirectoryEntry): ReactNode { + const extension = normalizeExtension(entry.extension); + + if (entry.isDirectory) { + if (entry.name.toLowerCase().endsWith(".app")) { + return ; + } + + return ; + } + + return getIconByExtension(extension); +} From 7fe895206b0cfe7b7fd19092bf5e6287cfeb12d2 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 04:53:11 -0300 Subject: [PATCH 02/14] feat: implement FileExplorerModal component for directory selection Added a new FileExplorerModal component to facilitate directory selection within the application. This modal allows users to navigate their file system, view directory contents, and select directories for download paths. Integrated necessary hooks and state management for loading directories and handling user interactions. Updated relevant styles and added new helper functions for file handling. --- .../common/file-explorer-modal/index.tsx | 406 ++++++++++++++++++ .../common/file-explorer-modal/styles.scss | 161 +++++++ .../src/components/common/index.ts | 1 + .../src/components/common/modal/styles.scss | 6 +- src/big-picture/src/helpers/index.ts | 1 + .../settings/download-directories-section.tsx | 49 ++- src/main/events/misc/get-path-info.ts | 30 ++ src/main/events/misc/index.ts | 3 + src/main/events/misc/list-drives.ts | 21 + src/main/events/misc/read-directory.ts | 81 ++++ src/preload/index.ts | 3 + src/renderer/src/declaration.d.ts | 17 + 12 files changed, 758 insertions(+), 21 deletions(-) create mode 100644 src/big-picture/src/components/common/file-explorer-modal/index.tsx create mode 100644 src/big-picture/src/components/common/file-explorer-modal/styles.scss create mode 100644 src/main/events/misc/get-path-info.ts create mode 100644 src/main/events/misc/list-drives.ts create mode 100644 src/main/events/misc/read-directory.ts diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx new file mode 100644 index 000000000..4e68f1008 --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -0,0 +1,406 @@ +import "./styles.scss"; + +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { CheckCircle, FolderIcon, FolderOpen } from "@phosphor-icons/react"; +import { Modal } from "../modal"; +import { VerticalFocusGroup } from "../vertical-focus-group"; +import { FocusItem } from "../focus-item"; +import { EmptyState } from "../empty-state"; +import { Skeleton } from "../skeleton"; +import { useNavigationScreenActions } from "../../../hooks"; +import { getEntryIcon, type DirectoryEntry } from "../../../helpers"; + +interface FileFilter { + name: string; + extensions: string[]; +} + +export interface FileExplorerModalProps { + visible: boolean; + onClose: () => void; + onSelect: (path: string) => void; + title: string; + initialPath?: string; + filters?: FileFilter[]; + selectDirectory?: boolean; +} + +function getParentPath(path: string): string | null { + if (!path) return null; + + const normalized = path.replace(/\\/g, "/").replace(/\/$/, ""); + + if (normalized === "/") return null; + if (/^[A-Za-z]:$/i.test(normalized)) return null; + + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) return null; + + const parent = normalized.substring(0, lastSlash) || "/"; + + if (/^[A-Za-z]:$/i.test(parent)) return parent + "/"; + + return parent; +} + +function formatSize(bytes: number): string { + if (bytes === 0) return ""; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function matchesFilters( + entry: DirectoryEntry, + filters?: FileFilter[], + directoryOnly?: boolean +): boolean { + if (directoryOnly && !entry.isDirectory) return false; + if (entry.isDirectory) return true; + if (!filters || filters.length === 0) return true; + + const allExtensions = filters.flatMap((f) => f.extensions); + if (allExtensions.includes("*")) return true; + + return allExtensions.includes(entry.extension); +} + +const SKELETON_COUNT = 6; + +export function FileExplorerModal({ + visible, + onClose, + onSelect, + title, + initialPath, + filters, + selectDirectory = false, +}: Readonly) { + const [currentPath, setCurrentPath] = useState(initialPath ?? ""); + const [entries, setEntries] = useState([]); + const [drives, setDrives] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [pathInputValue, setPathInputValue] = useState(initialPath ?? ""); + const pathInputRef = useRef(null); + const generatedId = useId(); + const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; + + useEffect(() => { + if (!visible) { + setError(null); + return; + } + + if (initialPath) { + setCurrentPath(initialPath); + } + }, [visible, initialPath]); + + useEffect(() => { + setPathInputValue(currentPath); + }, [currentPath]); + + useEffect(() => { + if (!visible) { + setEntries([]); + setDrives([]); + return; + } + + if (!currentPath) { + setEntries([]); + return; + } + + let cancelled = false; + + const load = async () => { + setIsLoading(true); + setError(null); + + try { + const result = + await globalThis.window.electron.readDirectory(currentPath); + if (!cancelled) { + setEntries(result); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to read directory" + ); + setEntries([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [visible, currentPath]); + + useEffect(() => { + if (!visible || currentPath) { + setDrives([]); + return; + } + + let cancelled = false; + + const loadDrives = async () => { + try { + const result = await globalThis.window.electron.listDrives(); + if (!cancelled) setDrives(result); + } catch { + if (!cancelled) setDrives(["/"]); + } + }; + + void loadDrives(); + + return () => { + cancelled = true; + }; + }, [visible, currentPath]); + + const filteredEntries = useMemo(() => { + if (drives.length > 0 && !currentPath) { + return []; + } + return entries.filter((entry) => + matchesFilters(entry, filters, selectDirectory) + ); + }, [entries, filters, drives, selectDirectory, currentPath]); + + const handleBPress = useCallback(() => { + if (!currentPath) { + onClose(); + return; + } + + const parent = getParentPath(currentPath); + if (parent) { + setCurrentPath(parent); + } else { + onClose(); + } + }, [currentPath, onClose]); + + const handleBHold = useCallback(() => { + onClose(); + }, [onClose]); + + useNavigationScreenActions( + visible + ? { + press: { b: handleBPress }, + hold: { b: handleBHold }, + } + : {} + ); + + const handleEntrySelect = useCallback( + (entry: DirectoryEntry) => { + if (entry.isDirectory) { + setCurrentPath(entry.path); + } else { + onSelect(entry.path); + } + }, + [onSelect] + ); + + const handleSelectThisDirectory = useCallback(() => { + onSelect(currentPath); + }, [currentPath, onSelect]); + + const handlePathEnter = useCallback(async () => { + const target = pathInputValue.trim(); + if (!target) return; + + try { + const info = await globalThis.window.electron.getPathInfo(target); + if (info.exists && info.isDirectory) { + setCurrentPath(target); + } else { + setPathInputValue(currentPath); + } + } catch { + setPathInputValue(currentPath); + } + }, [pathInputValue, currentPath]); + + const handlePathInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handlePathEnter(); + } + }, + [handlePathEnter] + ); + + const hasParent = Boolean(currentPath && getParentPath(currentPath)); + const handleModalBack = useCallback(() => { + const parent = getParentPath(currentPath); + if (parent) setCurrentPath(parent); + }, [currentPath]); + + const showSelectThisDir = selectDirectory && currentPath; + const showDriveList = !currentPath && drives.length > 0; + + return ( + +
+ {isLoading && ( +
+
+ {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + + ))} +
+
+ )} + + {error && ( +
+
+ {error} +
+
+ )} + + {!isLoading && !error && ( + +
+ pathInputRef.current?.focus() }} + > + setPathInputValue(e.target.value)} + onKeyDown={handlePathInputKeyDown} + /> + + +
+ + {showSelectThisDir && ( + + + + )} + + {showDriveList && ( + <> +
Drives
+ {drives.map((drive) => ( + { + setCurrentPath(drive); + }, + }} + asChild + > + + + ))} + + )} + + {filteredEntries.length === 0 && !showDriveList && !isLoading && ( + } + title="This folder is empty" + /> + )} + + {filteredEntries.map((entry) => ( + handleEntrySelect(entry) }} + asChild + > + + + ))} +
+ )} +
+
+ ); +} diff --git a/src/big-picture/src/components/common/file-explorer-modal/styles.scss b/src/big-picture/src/components/common/file-explorer-modal/styles.scss new file mode 100644 index 000000000..0b5a9e32c --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/styles.scss @@ -0,0 +1,161 @@ +.file-explorer { + display: flex; + flex-direction: column; + gap: calc(var(--spacing-unit) * 2); + min-height: 0; + flex: 1; + + &__path-input-wrapper { + position: relative; + display: flex; + align-items: center; + width: 100%; + + > [data-focus-wrapper] { + width: 100%; + } + } + + &__path-input { + width: 100%; + height: 44px; + padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 4); + padding-left: 46px; + border-radius: calc(var(--spacing-unit) * 2); + font-family: var(--font-space-grotesk); + font-size: 14px; + line-height: 16.59px; + border: 1px solid var(--secondary-border); + background-color: var(--background); + color: var(--text); + transition: border-color 0.2s ease-in-out; + + &:hover { + border-color: var(--secondary-hover); + } + + &:focus-visible, + [data-focus-visible="true"] & { + outline: none; + border-color: rgba(255, 255, 255, 0.6); + } + + &::placeholder { + color: var(--text-secondary); + } + } + + &__path-input-icon { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + left: 10px; + color: var(--text-secondary); + pointer-events: none; + } + + &__list { + flex: 1; + min-height: 0; + overflow-y: auto; + } + + &__skeleton-group { + display: flex; + flex-direction: column; + gap: calc(var(--spacing-unit) * 2); + padding: calc(var(--spacing-unit) * 2) 0; + } + + &__skeleton { + height: 36px; + border-radius: calc(var(--spacing-unit) * 1.5); + } + + &__status { + display: flex; + align-items: center; + justify-content: center; + padding: calc(var(--spacing-unit) * 8); + color: var(--text-secondary); + font-size: 0.875rem; + + &--error { + color: var(--text-error); + } + } + + &__section-label { + font-size: 0.75rem; + color: var(--text-secondary); + padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__item { + display: flex; + align-items: center; + gap: calc(var(--spacing-unit) * 2); + padding: calc(var(--spacing-unit) * 2) calc(var(--spacing-unit) * 3); + border-radius: calc(var(--spacing-unit) * 1.5); + cursor: pointer; + transition: background-color 0.15s ease; + color: var(--text); + font-size: 0.875rem; + width: 100%; + text-align: left; + border: none; + font-family: var(--font-space-grotesk), sans-serif; + background: transparent; + + &:hover { + background-color: var(--secondary-hover); + } + + &[data-focus-visible="true"] { + background-color: var(--secondary-hover); + outline: none; + } + + &--select-dir { + &:hover, + &[data-focus-visible="true"] { + background-color: var(--secondary-hover); + } + } + } + + &__item-icon { + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.7; + } + + &__item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-meta { + margin-left: auto; + font-size: 0.75rem; + color: var(--text-secondary); + opacity: 0; + transition: opacity 0.15s ease; + white-space: nowrap; + flex-shrink: 0; + } + + &__item:hover &__item-meta, + &__item[data-focus-visible="true"] &__item-meta { + opacity: 0.6; + } + + &__empty { + padding: calc(var(--spacing-unit) * 4) 0 calc(var(--spacing-unit) * 16) 0; + } +} diff --git a/src/big-picture/src/components/common/index.ts b/src/big-picture/src/components/common/index.ts index 925c7311c..0f49efa95 100644 --- a/src/big-picture/src/components/common/index.ts +++ b/src/big-picture/src/components/common/index.ts @@ -36,6 +36,7 @@ export * from "./diagnostics"; export * from "./dropdown-select"; export * from "./download-source-card"; export * from "./empty-state"; +export * from "./file-explorer-modal"; export * from "./context-menu"; export * from "./scroll-area"; export * from "./route-anchor"; diff --git a/src/big-picture/src/components/common/modal/styles.scss b/src/big-picture/src/components/common/modal/styles.scss index ede693755..c33dd8a64 100644 --- a/src/big-picture/src/components/common/modal/styles.scss +++ b/src/big-picture/src/components/common/modal/styles.scss @@ -4,7 +4,7 @@ overflow: hidden; width: min(100%, 42rem); - max-height: min(100%, calc(100vh - 4rem)); + max-height: min(100%, calc(100vh - 4rem), 60vh); border-radius: calc(var(--spacing-unit) * 3); background-color: var(--background); @@ -14,11 +14,11 @@ &__header { display: flex; position: relative; - align-items: flex-start; - justify-content: space-between; overflow: hidden; padding: calc(var(--spacing-unit) * 6); + gap: calc(var(--spacing-unit) * 2); + flex-shrink: 0; flex-direction: column; diff --git a/src/big-picture/src/helpers/index.ts b/src/big-picture/src/helpers/index.ts index bfc0b4bdb..0ba603f34 100644 --- a/src/big-picture/src/helpers/index.ts +++ b/src/big-picture/src/helpers/index.ts @@ -12,4 +12,5 @@ export * from "./library-toast"; export * from "./language"; export * from "./navigation"; export * from "./preferred-assets"; +export * from "./file-entry-icon"; export * from "./strings"; diff --git a/src/big-picture/src/pages/settings/download-directories-section.tsx b/src/big-picture/src/pages/settings/download-directories-section.tsx index cf5bcd480..57063ade3 100644 --- a/src/big-picture/src/pages/settings/download-directories-section.tsx +++ b/src/big-picture/src/pages/settings/download-directories-section.tsx @@ -31,6 +31,7 @@ import { ContextMenu, DropdownSelect, type DropdownSelectOption, + FileExplorerModal, GridFocusGroup, HorizontalFocusGroup, UserDiskItem, @@ -302,6 +303,7 @@ export function DownloadDirectoriesSection({ const [directoryMenu, setDirectoryMenu] = useState( null ); + const [filePickerOpen, setFilePickerOpen] = useState(false); useEffect(() => { let isMounted = true; @@ -458,27 +460,29 @@ export function DownloadDirectoriesSection({ return; } - const { filePaths } = await globalThis.window.electron.showOpenDialog({ - defaultPath: resolvedDirectories.defaultPath, - properties: ["openDirectory"], - }); - const nextPath = filePaths?.[0]; + setFilePickerOpen(true); + }, [canAddDirectory, defaultDownloadsPath, resolvedDirectories]); - if (!nextPath) return; + const handleFilePickerSelect = useCallback( + async (nextPath: string) => { + setFilePickerOpen(false); - const nextPreferences = addOptionalDownloadDirectory( - userPreferences, - nextPath, - defaultDownloadsPath - ); + if (!nextPath || !defaultDownloadsPath) return; - await persistDownloadDirectoryPreferences(nextPreferences); - }, [ - canAddDirectory, - defaultDownloadsPath, - resolvedDirectories, - userPreferences, - ]); + const nextPreferences = addOptionalDownloadDirectory( + userPreferences, + nextPath, + defaultDownloadsPath + ); + + await persistDownloadDirectoryPreferences(nextPreferences); + }, + [defaultDownloadsPath, userPreferences] + ); + + const handleFilePickerClose = useCallback(() => { + setFilePickerOpen(false); + }, []); const handleRemoveDirectory = useCallback( async (pathToRemove: string) => { @@ -646,6 +650,15 @@ export function DownloadDirectoriesSection({ restoreFocusId={directoryMenu?.restoreFocusId ?? null} onClose={() => setDirectoryMenu(null)} /> + + ); } diff --git a/src/main/events/misc/get-path-info.ts b/src/main/events/misc/get-path-info.ts new file mode 100644 index 000000000..8435862a0 --- /dev/null +++ b/src/main/events/misc/get-path-info.ts @@ -0,0 +1,30 @@ +import { stat } from "node:fs/promises"; +import { registerEvent } from "../register-event"; + +export interface PathInfo { + exists: boolean; + isDirectory: boolean; + isFile: boolean; +} + +const getPathInfo = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + try { + const stats = await stat(filePath); + return { + exists: true, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch { + return { + exists: false, + isDirectory: false, + isFile: false, + }; + } +}; + +registerEvent("getPathInfo", getPathInfo); diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts index bab7d8b6f..a1f29f706 100644 --- a/src/main/events/misc/index.ts +++ b/src/main/events/misc/index.ts @@ -17,3 +17,6 @@ import "./reset-common-redist-preflight"; import "./save-temp-file"; import "./show-item-in-folder"; import "./show-open-dialog"; +import "./read-directory"; +import "./get-path-info"; +import "./list-drives"; diff --git a/src/main/events/misc/list-drives.ts b/src/main/events/misc/list-drives.ts new file mode 100644 index 000000000..8f4540942 --- /dev/null +++ b/src/main/events/misc/list-drives.ts @@ -0,0 +1,21 @@ +import { execSync } from "node:child_process"; +import { platform } from "node:os"; +import { registerEvent } from "../register-event"; + +const listDrives = async (): Promise => { + if (platform() === "win32") { + const raw = execSync("wmic logicaldisk get name", { + encoding: "utf-8", + timeout: 5000, + }); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^[A-Za-z]:/.test(line)) + .map((drive) => drive.trimEnd()); + } + + return ["/"]; +}; + +registerEvent("listDrives", listDrives); diff --git a/src/main/events/misc/read-directory.ts b/src/main/events/misc/read-directory.ts new file mode 100644 index 000000000..474f4d399 --- /dev/null +++ b/src/main/events/misc/read-directory.ts @@ -0,0 +1,81 @@ +import { lstat, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { registerEvent } from "../register-event"; + +export interface DirectoryEntry { + name: string; + path: string; + isDirectory: boolean; + isFile: boolean; + extension: string; + size: number; + fileCount: number; +} + +const readDirectory = async ( + _event: Electron.IpcMainInvokeEvent, + dirPath: string +): Promise => { + const entries = await readdir(dirPath, { withFileTypes: true }); + + const result: DirectoryEntry[] = await Promise.all( + entries.map(async (entry) => { + const fullPath = join(dirPath, entry.name); + const name = entry.name; + const isMacApp = + entry.isDirectory() && name.toLowerCase().endsWith(".app"); + const isDirectory = entry.isDirectory() && !isMacApp; + const isFile = entry.isFile() || isMacApp; + + let ext = ""; + if (isFile && name.includes(".")) { + ext = name.split(".").pop()?.toLowerCase() ?? ""; + } + + let size = 0; + let fileCount = 0; + + if (isFile) { + try { + const stat = await lstat(fullPath); + size = stat.size; + } catch { + // Skip files that can't be accessed + } + } else if (isDirectory) { + try { + const dirEntries = await readdir(fullPath); + fileCount = dirEntries.length; + } catch { + // Skip directories that can't be accessed + } + } + + return { + name, + path: fullPath, + isDirectory, + isFile, + extension: ext, + size, + fileCount, + }; + }) + ); + + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + + result.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1; + } + return collator.compare(a.name, b.name); + }); + + return result; +}; + +registerEvent("readDirectory", readDirectory); diff --git a/src/preload/index.ts b/src/preload/index.ts index 7102ea2cf..54e283211 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -859,6 +859,9 @@ contextBridge.exposeInMainWorld("electron", { getCloudIframeUrl: () => ipcRenderer.invoke("getCloudIframeUrl"), showOpenDialog: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke("showOpenDialog", options), + readDirectory: (path: string) => ipcRenderer.invoke("readDirectory", path), + getPathInfo: (path: string) => ipcRenderer.invoke("getPathInfo", path), + listDrives: () => ipcRenderer.invoke("listDrives"), showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), getImageDataUrl: (imageUrl: string) => diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index d87d1aac6..b5f0e7e1a 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -663,6 +663,23 @@ declare global { showOpenDialog: ( options: Electron.OpenDialogOptions ) => Promise; + readDirectory: (path: string) => Promise< + Array<{ + name: string; + path: string; + isDirectory: boolean; + isFile: boolean; + extension: string; + size: number; + fileCount: number; + }> + >; + getPathInfo: (path: string) => Promise<{ + exists: boolean; + isDirectory: boolean; + isFile: boolean; + }>; + listDrives: () => Promise; showItemInFolder: (path: string) => Promise; getImageDataUrl: (imageUrl: string) => Promise; getProcessedFriendImage: ( From ad79d3b3fbe06533e6fd58470e97b4da808beb9c Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 06:15:17 -0300 Subject: [PATCH 03/14] refactor: restructure FileExplorerModal and introduce useFileExplorer hook --- .../common/file-explorer-modal/index.tsx | 363 +++--------------- .../common/file-explorer-modal/styles.scss | 4 + .../file-explorer-modal/use-file-explorer.ts | 239 ++++++++++++ .../common/file-explorer-modal/utils.ts | 51 +++ .../src/components/common/modal/styles.scss | 2 +- .../src/helpers/file-entry-icon.tsx | 1 - .../{read-directory.ts => file-system.ts} | 69 +++- src/main/events/misc/get-path-info.ts | 30 -- src/main/events/misc/index.ts | 4 +- src/main/events/misc/list-drives.ts | 21 - src/renderer/src/declaration.d.ts | 1 - 11 files changed, 410 insertions(+), 375 deletions(-) create mode 100644 src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts create mode 100644 src/big-picture/src/components/common/file-explorer-modal/utils.ts rename src/main/events/misc/{read-directory.ts => file-system.ts} (56%) delete mode 100644 src/main/events/misc/get-path-info.ts delete mode 100644 src/main/events/misc/list-drives.ts diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index 4e68f1008..9b9bf01bf 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -1,362 +1,119 @@ import "./styles.scss"; import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, - type KeyboardEvent, -} from "react"; -import { CheckCircle, FolderIcon, FolderOpen } from "@phosphor-icons/react"; + CheckCircleIcon, + FolderIcon, + FolderOpenIcon, +} from "@phosphor-icons/react"; import { Modal } from "../modal"; import { VerticalFocusGroup } from "../vertical-focus-group"; import { FocusItem } from "../focus-item"; import { EmptyState } from "../empty-state"; import { Skeleton } from "../skeleton"; -import { useNavigationScreenActions } from "../../../hooks"; -import { getEntryIcon, type DirectoryEntry } from "../../../helpers"; - -interface FileFilter { - name: string; - extensions: string[]; -} - -export interface FileExplorerModalProps { - visible: boolean; - onClose: () => void; - onSelect: (path: string) => void; - title: string; - initialPath?: string; - filters?: FileFilter[]; - selectDirectory?: boolean; -} - -function getParentPath(path: string): string | null { - if (!path) return null; - - const normalized = path.replace(/\\/g, "/").replace(/\/$/, ""); - - if (normalized === "/") return null; - if (/^[A-Za-z]:$/i.test(normalized)) return null; - - const lastSlash = normalized.lastIndexOf("/"); - if (lastSlash === -1) return null; - - const parent = normalized.substring(0, lastSlash) || "/"; - - if (/^[A-Za-z]:$/i.test(parent)) return parent + "/"; - - return parent; -} - -function formatSize(bytes: number): string { - if (bytes === 0) return ""; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; -} - -function matchesFilters( - entry: DirectoryEntry, - filters?: FileFilter[], - directoryOnly?: boolean -): boolean { - if (directoryOnly && !entry.isDirectory) return false; - if (entry.isDirectory) return true; - if (!filters || filters.length === 0) return true; - - const allExtensions = filters.flatMap((f) => f.extensions); - if (allExtensions.includes("*")) return true; - - return allExtensions.includes(entry.extension); -} - -const SKELETON_COUNT = 6; - -export function FileExplorerModal({ - visible, - onClose, - onSelect, - title, - initialPath, - filters, - selectDirectory = false, -}: Readonly) { - const [currentPath, setCurrentPath] = useState(initialPath ?? ""); - const [entries, setEntries] = useState([]); - const [drives, setDrives] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [pathInputValue, setPathInputValue] = useState(initialPath ?? ""); - const pathInputRef = useRef(null); - const generatedId = useId(); - const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; - - useEffect(() => { - if (!visible) { - setError(null); - return; - } - - if (initialPath) { - setCurrentPath(initialPath); - } - }, [visible, initialPath]); - - useEffect(() => { - setPathInputValue(currentPath); - }, [currentPath]); - - useEffect(() => { - if (!visible) { - setEntries([]); - setDrives([]); - return; - } - - if (!currentPath) { - setEntries([]); - return; - } - - let cancelled = false; - - const load = async () => { - setIsLoading(true); - setError(null); - - try { - const result = - await globalThis.window.electron.readDirectory(currentPath); - if (!cancelled) { - setEntries(result); - } - } catch (err) { - if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to read directory" - ); - setEntries([]); - } - } finally { - if (!cancelled) { - setIsLoading(false); - } - } - }; - - void load(); - - return () => { - cancelled = true; - }; - }, [visible, currentPath]); - - useEffect(() => { - if (!visible || currentPath) { - setDrives([]); - return; - } - - let cancelled = false; - - const loadDrives = async () => { - try { - const result = await globalThis.window.electron.listDrives(); - if (!cancelled) setDrives(result); - } catch { - if (!cancelled) setDrives(["/"]); - } - }; - - void loadDrives(); - - return () => { - cancelled = true; - }; - }, [visible, currentPath]); - - const filteredEntries = useMemo(() => { - if (drives.length > 0 && !currentPath) { - return []; - } - return entries.filter((entry) => - matchesFilters(entry, filters, selectDirectory) - ); - }, [entries, filters, drives, selectDirectory, currentPath]); - - const handleBPress = useCallback(() => { - if (!currentPath) { - onClose(); - return; - } - - const parent = getParentPath(currentPath); - if (parent) { - setCurrentPath(parent); - } else { - onClose(); - } - }, [currentPath, onClose]); - - const handleBHold = useCallback(() => { - onClose(); - }, [onClose]); - - useNavigationScreenActions( - visible - ? { - press: { b: handleBPress }, - hold: { b: handleBHold }, - } - : {} - ); - - const handleEntrySelect = useCallback( - (entry: DirectoryEntry) => { - if (entry.isDirectory) { - setCurrentPath(entry.path); - } else { - onSelect(entry.path); - } - }, - [onSelect] - ); - - const handleSelectThisDirectory = useCallback(() => { - onSelect(currentPath); - }, [currentPath, onSelect]); - - const handlePathEnter = useCallback(async () => { - const target = pathInputValue.trim(); - if (!target) return; - - try { - const info = await globalThis.window.electron.getPathInfo(target); - if (info.exists && info.isDirectory) { - setCurrentPath(target); - } else { - setPathInputValue(currentPath); - } - } catch { - setPathInputValue(currentPath); - } - }, [pathInputValue, currentPath]); - - const handlePathInputKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handlePathEnter(); - } - }, - [handlePathEnter] - ); +import { getEntryIcon } from "../../../helpers"; +import { getEntryMeta } from "./utils"; +import { + useFileExplorer, + type FileExplorerModalProps, +} from "./use-file-explorer"; - const hasParent = Boolean(currentPath && getParentPath(currentPath)); - const handleModalBack = useCallback(() => { - const parent = getParentPath(currentPath); - if (parent) setCurrentPath(parent); - }, [currentPath]); +export { type FileExplorerModalProps } from "./use-file-explorer"; +export { type FileFilter } from "./utils"; - const showSelectThisDir = selectDirectory && currentPath; - const showDriveList = !currentPath && drives.length > 0; +export function FileExplorerModal(props: Readonly) { + const vm = useFileExplorer(props); return (
- {isLoading && ( + {vm.isLoading && (
- {Array.from({ length: SKELETON_COUNT }, (_, i) => ( + {Array.from({ length: vm.SKELETON_COUNT }, (_, i) => ( ))}
)} - {error && ( + {vm.error && (
- {error} + {vm.error}
)} - {!isLoading && !error && ( + {!vm.isLoading && !vm.error && (
pathInputRef.current?.focus() }} + actions={{ primary: () => vm.pathInputRef.current?.focus() }} > setPathInputValue(e.target.value)} - onKeyDown={handlePathInputKeyDown} + placeholder={vm.currentPath || vm.PATH_INPUT_PLACEHOLDER} + value={vm.pathInputValue} + onChange={(e) => vm.setPathInputValue(e.target.value)} + onKeyDown={vm.handlePathInputKeyDown} /> -
- {showSelectThisDir && ( + {vm.showSelectThisDir && ( )} - {showDriveList && ( + {vm.showDriveList && ( <> -
Drives
- {drives.map((drive) => ( +
+ {vm.DRIVES_LABEL} +
+ + {vm.drives.map((drive) => ( { - setCurrentPath(drive); - }, - }} + actions={{ primary: () => vm.navigateToDrive(drive) }} asChild > diff --git a/src/big-picture/src/components/common/file-explorer-modal/styles.scss b/src/big-picture/src/components/common/file-explorer-modal/styles.scss index 0b5a9e32c..8baa3a600 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/styles.scss +++ b/src/big-picture/src/components/common/file-explorer-modal/styles.scss @@ -1,3 +1,7 @@ +.file-explorer-modal { + max-height: min(100%, calc(100vh - 4rem), 60vh); +} + .file-explorer { display: flex; flex-direction: column; diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts new file mode 100644 index 000000000..9ae9ab111 --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { useNavigationScreenActions } from "../../../hooks"; +import { type DirectoryEntry } from "../../../helpers"; +import { + getParentPath, + matchesFilters, + normalizeFilters, + type FileFilter, +} from "./utils"; + +const SKELETON_COUNT = 6; +const PATH_INPUT_PLACEHOLDER = "Select a location"; +const DRIVES_LABEL = "Drives"; +const EMPTY_FOLDER_TITLE = "This folder is empty"; + +export interface FileExplorerModalProps { + visible: boolean; + onClose: () => void; + onSelect: (path: string) => void; + title: string; + initialPath?: string; + filters?: FileFilter[]; + selectDirectory?: boolean; +} + +export function useFileExplorer({ + visible, + onClose, + onSelect, + initialPath, + filters, + selectDirectory = false, +}: Readonly) { + const [currentPath, setCurrentPath] = useState(initialPath ?? ""); + const [entries, setEntries] = useState([]); + const [drives, setDrives] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [pathInputValue, setPathInputValue] = useState(initialPath ?? ""); + const pathInputRef = useRef(null); + const generatedId = useId(); + const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; + + useEffect(() => { + if (!visible) { + setError(null); + return; + } + + if (initialPath) { + setCurrentPath(initialPath); + } + }, [visible, initialPath]); + + useEffect(() => { + setPathInputValue(currentPath); + }, [currentPath]); + + useEffect(() => { + if (!visible) { + setEntries([]); + setDrives([]); + return; + } + + if (!currentPath) { + setEntries([]); + return; + } + + let cancelled = false; + + const load = async () => { + setIsLoading(true); + setError(null); + + try { + const result = + await globalThis.window.electron.readDirectory(currentPath); + + if (!cancelled) setEntries(result); + } catch (err) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to read directory" + ); + + setEntries([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [visible, currentPath]); + + useEffect(() => { + if (!visible || currentPath) { + setDrives([]); + return; + } + + let cancelled = false; + + const loadDrives = async () => { + try { + const result = await globalThis.window.electron.listDrives(); + if (!cancelled) setDrives(result); + } catch { + if (!cancelled) setDrives(["/"]); + } + }; + + void loadDrives(); + + return () => { + cancelled = true; + }; + }, [visible, currentPath]); + + const allowedExtensions = useMemo(() => normalizeFilters(filters), [filters]); + + const filteredEntries = useMemo(() => { + if (drives.length > 0 && !currentPath) return []; + return entries.filter((entry) => + matchesFilters(entry, allowedExtensions, selectDirectory) + ); + }, [entries, allowedExtensions, drives, selectDirectory, currentPath]); + + const handleBPress = useCallback(() => { + if (!currentPath) return onClose(); + + const parent = getParentPath(currentPath); + if (!parent) return onClose(); + + setCurrentPath(parent); + }, [currentPath, onClose]); + + const handleBHold = useCallback(() => { + onClose(); + }, [onClose]); + + useNavigationScreenActions( + visible + ? { + press: { b: handleBPress }, + hold: { b: handleBHold }, + } + : {} + ); + + const handleEntrySelect = useCallback( + (entry: DirectoryEntry) => { + if (entry.isDirectory) setCurrentPath(entry.path); + else onSelect(entry.path); + }, + [onSelect] + ); + + const handleSelectThisDirectory = useCallback(() => { + onSelect(currentPath); + }, [currentPath, onSelect]); + + const handlePathEnter = useCallback(async () => { + const target = pathInputValue.trim(); + if (!target) return; + + try { + const info = await globalThis.window.electron.getPathInfo(target); + if (info.exists && info.isDirectory) { + setCurrentPath(target); + } else setPathInputValue(currentPath); + } catch { + setPathInputValue(currentPath); + } + }, [pathInputValue, currentPath]); + + const handlePathInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handlePathEnter(); + } + }, + [handlePathEnter] + ); + + const hasParent = Boolean(currentPath && getParentPath(currentPath)); + const goToParent = useCallback(() => { + const parent = getParentPath(currentPath); + if (parent) setCurrentPath(parent); + }, [currentPath]); + + const showSelectThisDir = selectDirectory && currentPath; + const showDriveList = !currentPath && drives.length > 0; + + const navigateToDrive = useCallback((drive: string) => { + setCurrentPath(drive); + }, []); + + return { + currentPath, + pathInputValue, + setPathInputValue, + pathInputRef, + fileListRegionId, + isLoading, + error, + drives, + filteredEntries, + SKELETON_COUNT, + PATH_INPUT_PLACEHOLDER, + DRIVES_LABEL, + EMPTY_FOLDER_TITLE, + showSelectThisDir, + showDriveList, + hasParent, + handleEntrySelect, + handleSelectThisDirectory, + handlePathInputKeyDown, + goToParent, + navigateToDrive, + }; +} diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts new file mode 100644 index 000000000..088284a88 --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -0,0 +1,51 @@ +import type { DirectoryEntry } from "../../../helpers"; +import { formatBytes } from "@shared"; + +export interface FileFilter { + name: string; + extensions: string[]; +} + +const WINDOWS_DRIVE_RE = /^[A-Za-z]:$/; + +export function getParentPath(path: string): string | null { + if (!path) return null; + + const normalized = path.replaceAll("\\", "/").replace(/\/$/, ""); + + if (normalized === "/") return null; + if (WINDOWS_DRIVE_RE.test(normalized)) return null; + + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) return null; + + const parent = normalized.substring(0, lastSlash) || "/"; + + return WINDOWS_DRIVE_RE.test(parent) ? parent + "/" : parent; +} + +export function getEntryMeta(entry: DirectoryEntry): string { + if (!entry.isFile) return ""; + return formatBytes(entry.size); +} + +export function normalizeFilters(filters?: FileFilter[]): Set | null { + if (!filters || filters.length === 0) return null; + + const allExtensions = filters.flatMap((f) => f.extensions); + if (allExtensions.includes("*")) return null; + + return new Set(allExtensions.map((ext) => ext.toLowerCase())); +} + +export function matchesFilters( + entry: DirectoryEntry, + allowedExtensions: Set | null, + directoryOnly: boolean +): boolean { + if (directoryOnly && !entry.isDirectory) return false; + if (entry.isDirectory) return true; + if (!allowedExtensions) return true; + + return allowedExtensions.has(entry.extension); +} diff --git a/src/big-picture/src/components/common/modal/styles.scss b/src/big-picture/src/components/common/modal/styles.scss index c33dd8a64..38c25787f 100644 --- a/src/big-picture/src/components/common/modal/styles.scss +++ b/src/big-picture/src/components/common/modal/styles.scss @@ -4,7 +4,7 @@ overflow: hidden; width: min(100%, 42rem); - max-height: min(100%, calc(100vh - 4rem), 60vh); + max-height: min(100%, calc(100vh - 4rem)); border-radius: calc(var(--spacing-unit) * 3); background-color: var(--background); diff --git a/src/big-picture/src/helpers/file-entry-icon.tsx b/src/big-picture/src/helpers/file-entry-icon.tsx index ba3fb0762..be425cffa 100644 --- a/src/big-picture/src/helpers/file-entry-icon.tsx +++ b/src/big-picture/src/helpers/file-entry-icon.tsx @@ -41,7 +41,6 @@ export interface DirectoryEntry { isFile: boolean; extension: string; size: number; - fileCount: number; } const iconProps = { diff --git a/src/main/events/misc/read-directory.ts b/src/main/events/misc/file-system.ts similarity index 56% rename from src/main/events/misc/read-directory.ts rename to src/main/events/misc/file-system.ts index 474f4d399..94e39e991 100644 --- a/src/main/events/misc/read-directory.ts +++ b/src/main/events/misc/file-system.ts @@ -1,5 +1,6 @@ -import { lstat, readdir } from "node:fs/promises"; +import { access, readdir, stat } from "node:fs/promises"; import { join } from "node:path"; +import { platform } from "node:os"; import { registerEvent } from "../register-event"; export interface DirectoryEntry { @@ -9,7 +10,12 @@ export interface DirectoryEntry { isFile: boolean; extension: string; size: number; - fileCount: number; +} + +export interface PathInfo { + exists: boolean; + isDirectory: boolean; + isFile: boolean; } const readDirectory = async ( @@ -33,21 +39,13 @@ const readDirectory = async ( } let size = 0; - let fileCount = 0; if (isFile) { try { - const stat = await lstat(fullPath); - size = stat.size; + const stats = await stat(fullPath); + size = stats.size; } catch { - // Skip files that can't be accessed - } - } else if (isDirectory) { - try { - const dirEntries = await readdir(fullPath); - fileCount = dirEntries.length; - } catch { - // Skip directories that can't be accessed + // File may not be accessible } } @@ -58,7 +56,6 @@ const readDirectory = async ( isFile, extension: ext, size, - fileCount, }; }) ); @@ -78,4 +75,48 @@ const readDirectory = async ( return result; }; +const getPathInfo = async ( + _event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + try { + const stats = await stat(filePath); + return { + exists: true, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + }; + } catch { + return { + exists: false, + isDirectory: false, + isFile: false, + }; + } +}; + +const listDrives = async (): Promise => { + if (platform() === "win32") { + const drives: string[] = []; + + for (let i = 0; i < 26; i++) { + const letter = String.fromCodePoint(65 + i); + const root = `${letter}:\\`; + + try { + await access(root); + drives.push(root); + } catch { + // Drive doesn't exist + } + } + + return drives; + } + + return ["/"]; +}; + registerEvent("readDirectory", readDirectory); +registerEvent("getPathInfo", getPathInfo); +registerEvent("listDrives", listDrives); diff --git a/src/main/events/misc/get-path-info.ts b/src/main/events/misc/get-path-info.ts deleted file mode 100644 index 8435862a0..000000000 --- a/src/main/events/misc/get-path-info.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { stat } from "node:fs/promises"; -import { registerEvent } from "../register-event"; - -export interface PathInfo { - exists: boolean; - isDirectory: boolean; - isFile: boolean; -} - -const getPathInfo = async ( - _event: Electron.IpcMainInvokeEvent, - filePath: string -): Promise => { - try { - const stats = await stat(filePath); - return { - exists: true, - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - }; - } catch { - return { - exists: false, - isDirectory: false, - isFile: false, - }; - } -}; - -registerEvent("getPathInfo", getPathInfo); diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts index a1f29f706..3837498b9 100644 --- a/src/main/events/misc/index.ts +++ b/src/main/events/misc/index.ts @@ -17,6 +17,4 @@ import "./reset-common-redist-preflight"; import "./save-temp-file"; import "./show-item-in-folder"; import "./show-open-dialog"; -import "./read-directory"; -import "./get-path-info"; -import "./list-drives"; +import "./file-system"; diff --git a/src/main/events/misc/list-drives.ts b/src/main/events/misc/list-drives.ts deleted file mode 100644 index 8f4540942..000000000 --- a/src/main/events/misc/list-drives.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { execSync } from "node:child_process"; -import { platform } from "node:os"; -import { registerEvent } from "../register-event"; - -const listDrives = async (): Promise => { - if (platform() === "win32") { - const raw = execSync("wmic logicaldisk get name", { - encoding: "utf-8", - timeout: 5000, - }); - return raw - .split("\n") - .map((line) => line.trim()) - .filter((line) => /^[A-Za-z]:/.test(line)) - .map((drive) => drive.trimEnd()); - } - - return ["/"]; -}; - -registerEvent("listDrives", listDrives); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index b5f0e7e1a..93d24f8e3 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -671,7 +671,6 @@ declare global { isFile: boolean; extension: string; size: number; - fileCount: number; }> >; getPathInfo: (path: string) => Promise<{ From 71d9762b449245ec9f6f6f92c36a39eec9bc0fee Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 06:52:38 -0300 Subject: [PATCH 04/14] fix: update file explorer modal titles and error handling Refactored the FileExplorerModal to use dynamic titles for empty states and improved error handling in the useFileExplorer hook. Introduced a new error message mapping for better user feedback and ensured proper path management when navigating directories. Updated the preload API to streamline file system interactions. --- .../common/file-explorer-modal/index.tsx | 2 +- .../file-explorer-modal/use-file-explorer.ts | 54 ++++++++++++++----- src/main/events/misc/file-system.ts | 19 ++++++- src/preload/index.ts | 10 ++-- src/renderer/src/declaration.d.ts | 36 +++++++------ 5 files changed, 86 insertions(+), 35 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index 9b9bf01bf..baa91c737 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -129,7 +129,7 @@ export function FileExplorerModal(props: Readonly) { } - title={vm.EMPTY_FOLDER_TITLE} + title={vm.emptyTitle} /> )} diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index 9ae9ab111..7bf35ad59 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -20,6 +20,26 @@ const SKELETON_COUNT = 6; const PATH_INPUT_PLACEHOLDER = "Select a location"; const DRIVES_LABEL = "Drives"; const EMPTY_FOLDER_TITLE = "This folder is empty"; +const EMPTY_DIRECTORY_CHOICE_TITLE = "No folders found"; + +const ERROR_MESSAGES: Record = { + EACCES: "You don't have permission to open this location.", + ENOENT: "This location doesn't exist.", + ENOTDIR: "This is not a directory.", +}; + +function getErrorMessage(err: unknown): string { + const code = + err instanceof Error && "code" in err + ? (err as Record).code + : undefined; + + if (typeof code === "string") { + return ERROR_MESSAGES[code] ?? "This location can't be opened."; + } + + return "This location can't be opened."; +} export interface FileExplorerModalProps { visible: boolean; @@ -89,10 +109,7 @@ export function useFileExplorer({ if (!cancelled) setEntries(result); } catch (err) { if (!cancelled) { - setError( - err instanceof Error ? err.message : "Failed to read directory" - ); - + setError(getErrorMessage(err)); setEntries([]); } } finally { @@ -122,7 +139,7 @@ export function useFileExplorer({ const result = await globalThis.window.electron.listDrives(); if (!cancelled) setDrives(result); } catch { - if (!cancelled) setDrives(["/"]); + // Drives failed to load — user can type a path manually } }; @@ -146,10 +163,15 @@ export function useFileExplorer({ if (!currentPath) return onClose(); const parent = getParentPath(currentPath); - if (!parent) return onClose(); + if (parent) return setCurrentPath(parent); + + if (drives.length > 0) { + setCurrentPath(""); + return; + } - setCurrentPath(parent); - }, [currentPath, onClose]); + onClose(); + }, [currentPath, drives.length, onClose]); const handleBHold = useCallback(() => { onClose(); @@ -200,11 +222,17 @@ export function useFileExplorer({ [handlePathEnter] ); - const hasParent = Boolean(currentPath && getParentPath(currentPath)); + const hasParent = Boolean( + (currentPath && getParentPath(currentPath)) || + (currentPath && drives.length > 0) + ); + const goToParent = useCallback(() => { const parent = getParentPath(currentPath); - if (parent) setCurrentPath(parent); - }, [currentPath]); + if (parent) return setCurrentPath(parent); + + if (drives.length > 0) setCurrentPath(""); + }, [currentPath, drives.length]); const showSelectThisDir = selectDirectory && currentPath; const showDriveList = !currentPath && drives.length > 0; @@ -226,7 +254,9 @@ export function useFileExplorer({ SKELETON_COUNT, PATH_INPUT_PLACEHOLDER, DRIVES_LABEL, - EMPTY_FOLDER_TITLE, + emptyTitle: selectDirectory + ? EMPTY_DIRECTORY_CHOICE_TITLE + : EMPTY_FOLDER_TITLE, showSelectThisDir, showDriveList, hasParent, diff --git a/src/main/events/misc/file-system.ts b/src/main/events/misc/file-system.ts index 94e39e991..005bb5a2c 100644 --- a/src/main/events/misc/file-system.ts +++ b/src/main/events/misc/file-system.ts @@ -18,10 +18,23 @@ export interface PathInfo { isFile: boolean; } +function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void { + if (!event.senderFrame) { + throw new Error("Unauthorized IPC sender"); + } + + const url = event.senderFrame.url; + if (!url.startsWith("app://") && !url.startsWith("file://")) { + throw new Error("Unauthorized IPC sender"); + } +} + const readDirectory = async ( - _event: Electron.IpcMainInvokeEvent, + event: Electron.IpcMainInvokeEvent, dirPath: string ): Promise => { + assertTrustedSender(event); + const entries = await readdir(dirPath, { withFileTypes: true }); const result: DirectoryEntry[] = await Promise.all( @@ -76,9 +89,11 @@ const readDirectory = async ( }; const getPathInfo = async ( - _event: Electron.IpcMainInvokeEvent, + event: Electron.IpcMainInvokeEvent, filePath: string ): Promise => { + assertTrustedSender(event); + try { const stats = await stat(filePath); return { diff --git a/src/preload/index.ts b/src/preload/index.ts index 54e283211..064db20f6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -39,6 +39,12 @@ import type { import type { AuthPage } from "@shared"; import type { AxiosProgressEvent } from "axios"; +const fileExplorerApi = { + readDirectory: (path: string) => ipcRenderer.invoke("readDirectory", path), + getPathInfo: (path: string) => ipcRenderer.invoke("getPathInfo", path), + listDrives: () => ipcRenderer.invoke("listDrives"), +}; + contextBridge.exposeInMainWorld("electron", { /* Torrenting */ startGameDownload: (payload: StartGameDownloadPayload) => @@ -859,9 +865,7 @@ contextBridge.exposeInMainWorld("electron", { getCloudIframeUrl: () => ipcRenderer.invoke("getCloudIframeUrl"), showOpenDialog: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke("showOpenDialog", options), - readDirectory: (path: string) => ipcRenderer.invoke("readDirectory", path), - getPathInfo: (path: string) => ipcRenderer.invoke("getPathInfo", path), - listDrives: () => ipcRenderer.invoke("listDrives"), + ...fileExplorerApi, showItemInFolder: (path: string) => ipcRenderer.invoke("showItemInFolder", path), getImageDataUrl: (imageUrl: string) => diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 93d24f8e3..050ec3b98 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -72,6 +72,21 @@ declare global { export default content; } + type FileExplorerEntry = { + name: string; + path: string; + isDirectory: boolean; + isFile: boolean; + extension: string; + size: number; + }; + + type FileExplorerPathInfo = { + exists: boolean; + isDirectory: boolean; + isFile: boolean; + }; + interface Electron { /* Torrenting */ startGameDownload: ( @@ -663,21 +678,8 @@ declare global { showOpenDialog: ( options: Electron.OpenDialogOptions ) => Promise; - readDirectory: (path: string) => Promise< - Array<{ - name: string; - path: string; - isDirectory: boolean; - isFile: boolean; - extension: string; - size: number; - }> - >; - getPathInfo: (path: string) => Promise<{ - exists: boolean; - isDirectory: boolean; - isFile: boolean; - }>; + readDirectory: (path: string) => Promise; + getPathInfo: (path: string) => Promise; listDrives: () => Promise; showItemInFolder: (path: string) => Promise; getImageDataUrl: (imageUrl: string) => Promise; @@ -948,8 +950,8 @@ declare global { cancelGameTransfer: (shop: GameShop, objectId: string) => Promise; /* Event listeners for transfer progress */ - on: (channel: string, listener: (...args: any[]) => void) => void; - off: (channel: string, listener: (...args: any[]) => void) => void; + on: (channel: string, listener: (...args: unknown[]) => void) => void; + off: (channel: string, listener: (...args: unknown[]) => void) => void; } interface Window { From ef0ecc7d92c34d4ea9e240a59b65e65a3c4b5aa0 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 07:01:50 -0300 Subject: [PATCH 05/14] fix: update type definitions for event listener methods Modified the type definitions in declaration.d.ts to use rest parameters for the listener functions in the on and off methods. This change enhances type safety and aligns with TypeScript best practices. --- src/renderer/src/declaration.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 050ec3b98..eed8b7836 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -950,8 +950,8 @@ declare global { cancelGameTransfer: (shop: GameShop, objectId: string) => Promise; /* Event listeners for transfer progress */ - on: (channel: string, listener: (...args: unknown[]) => void) => void; - off: (channel: string, listener: (...args: unknown[]) => void) => void; + on: (channel: string, listener: (...args) => void) => void; + off: (channel: string, listener: (...args) => void) => void; } interface Window { From 844494ccaddcbe80de6b86200645c901f9ffbb4e Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 07:30:14 -0300 Subject: [PATCH 06/14] refactor: enhance file explorer modal integration and state management Updated the useFileExplorer hook to resolve the initial path dynamically, improving path management and user experience. Refactored the GameCompatibilitySettingsTab and GameCustomizationSettingsTab components to integrate the FileExplorerModal for selecting wine prefixes and assets, respectively. Adjusted state handling and callback functions for better clarity and functionality across the game settings modal components. --- .../file-explorer-modal/use-file-explorer.ts | 37 ++- .../game-settings-modal/compatibility-tab.tsx | 48 ++-- .../game-settings-modal/customization-tab.tsx | 231 ++++++++++-------- .../game/game-settings-modal/launch-tab.tsx | 173 +++++++++---- .../use-game-settings-modal-state.ts | 132 +++------- 5 files changed, 344 insertions(+), 277 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index 7bf35ad59..81d30f023 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -51,6 +51,27 @@ export interface FileExplorerModalProps { selectDirectory?: boolean; } +function resolveStartPath(initialPath?: string): Promise { + if (initialPath) { + return globalThis.window.electron + .getPathInfo(initialPath) + .then((info) => + info.exists && info.isFile + ? (getParentPath(initialPath) ?? initialPath) + : initialPath + ) + .catch(() => initialPath); + } + + return globalThis.window.electron + .getUserPreferences() + .then((prefs) => prefs?.downloadsPath) + .catch(() => null) + .then( + (path) => path ?? globalThis.window.electron.getDefaultDownloadsPath() + ); +} + export function useFileExplorer({ visible, onClose, @@ -59,12 +80,12 @@ export function useFileExplorer({ filters, selectDirectory = false, }: Readonly) { - const [currentPath, setCurrentPath] = useState(initialPath ?? ""); + const [currentPath, setCurrentPath] = useState(""); const [entries, setEntries] = useState([]); const [drives, setDrives] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [pathInputValue, setPathInputValue] = useState(initialPath ?? ""); + const [pathInputValue, setPathInputValue] = useState(""); const pathInputRef = useRef(null); const generatedId = useId(); const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; @@ -75,9 +96,15 @@ export function useFileExplorer({ return; } - if (initialPath) { - setCurrentPath(initialPath); - } + let cancelled = false; + + resolveStartPath(initialPath).then((path) => { + if (!cancelled) setCurrentPath(path); + }); + + return () => { + cancelled = true; + }; }, [visible, initialPath]); useEffect(() => { diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx index c7f9f71ae..efbbdde93 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { FolderOpen, Trash } from "@phosphor-icons/react"; +import { FolderOpenIcon, TrashIcon } from "@phosphor-icons/react"; import type { LibraryGame, ProtonVersion } from "@types"; import { Button, Checkbox, + FileExplorerModal, HorizontalFocusGroup, Input, Radio, @@ -54,7 +55,6 @@ type ElectronCompatibilityBridge = Pick< | "isGamemodeAvailable" | "isMangohudAvailable" | "getDefaultWinePrefixSelectionPath" - | "showOpenDialog" | "selectGameWinePrefix" | "selectGameProtonPath" | "toggleGameGamemode" @@ -84,6 +84,7 @@ export function GameCompatibilitySettingsTab({ const [autoRunMangohud, setAutoRunMangohud] = useState( game.autoRunMangohud ?? false ); + const [winePickerOpen, setWinePickerOpen] = useState(false); useEffect(() => { setSelectedProtonPath(game.protonPath ?? ""); @@ -150,23 +151,18 @@ export function GameCompatibilitySettingsTab({ return options; }, [protonVersions, t, getProtonSourceDescription]); - const handleSelectWinePrefix = useCallback(async () => { - const defaultPath = await electron.getDefaultWinePrefixSelectionPath(); + const handleSelectWinePrefix = useCallback(() => { + setWinePickerOpen(true); + }, []); - const { filePaths } = await electron.showOpenDialog({ - properties: ["openDirectory"], - defaultPath: winePrefixPath ?? defaultPath ?? "", - }); - - if (filePaths?.length) { - await electron.selectGameWinePrefix( - game.shop, - game.objectId, - filePaths[0] - ); - setWinePrefixPath(filePaths[0]); - } - }, [electron, game.shop, game.objectId, winePrefixPath]); + const handleWinePrefixPicked = useCallback( + async (path: string) => { + setWinePickerOpen(false); + await electron.selectGameWinePrefix(game.shop, game.objectId, path); + setWinePrefixPath(path); + }, + [electron, game.shop, game.objectId] + ); const handleClearWinePrefix = useCallback(async () => { await electron.selectGameWinePrefix(game.shop, game.objectId, null); @@ -250,10 +246,8 @@ export function GameCompatibilitySettingsTab({ - + + +
- - - + + + + + ); } diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx index 14936d139..b7272ce75 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx @@ -2,22 +2,25 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import { getSkuRegion, getSkuRegionFlag, + platformToSystem, type SkuRegion, } from "@renderer/helpers"; import type { LibraryGame, ShortcutLocation } from "@types"; import { DiscIcon } from "@phosphor-icons/react"; import { FolderOpen, HardDrive, Monitor, Trash } from "lucide-react"; -import type { ReactNode } from "react"; +import { useCallback, type ReactNode, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Button, Checkbox, DropdownSelect, + FileExplorerModal, FocusItem, HorizontalFocusGroup, Input, Tooltip, Typography, + type FileFilter, VerticalFocusGroup, } from "../../../common"; import { SettingsSection } from "../../../../pages/settings/settings-section"; @@ -79,7 +82,7 @@ export interface GameLaunchSettingsProps { creatingSteamShortcut: boolean; steamShortcutExists: boolean; shouldShowCreateStartMenuShortcut: boolean; - onChangeExecutableLocation: () => Promise; + onProcessExecPath: (path: string) => Promise; onClearExecutablePath: () => Promise; onOpenSaveFolder: () => Promise; onChangeLaunchOptions: (value: string) => void; @@ -90,7 +93,7 @@ export interface GameLaunchSettingsProps { onDeleteSteamShortcut: () => Promise; onSelectDisc: (path: string) => Promise; onToggleDontAskDiscSelection: (checked: boolean) => Promise; - onAddDiscFile: () => Promise; + onProcessDiscPath: (path: string) => Promise; onRemoveSelectedDisc: () => Promise; onRemoveAllDiscs: () => Promise; } @@ -100,7 +103,7 @@ interface LaunchboxDiscsSectionProps { selectedDisc: NonNullable[number] | null; dontAskDiscSelection: LibraryGame["dontAskDiscSelection"]; onSelectDisc: (path: string) => Promise; - onAddDiscFile: () => Promise; + onAddDiscFile: () => void; onRemoveSelectedDisc: () => Promise; onRemoveAllDiscs: () => Promise; onToggleDontAskDiscSelection: (checked: boolean) => Promise; @@ -161,9 +164,7 @@ function LaunchboxDiscsSection({ focusId={GAME_LAUNCH_SETTINGS_PRIMARY_CONTROL_ID} variant="primary" icon={} - onClick={() => { - void onAddDiscFile(); - }} + onClick={() => onAddDiscFile()} > {t("add_disc")} @@ -176,9 +177,7 @@ function LaunchboxDiscsSection({ focusId={GAME_LAUNCH_SETTINGS_ADD_DISC_FILE_ID} variant="secondary" icon={} - onClick={() => { - void onAddDiscFile(); - }} + onClick={() => onAddDiscFile()} > {t("add_disc")} @@ -229,7 +228,7 @@ interface ExecutableSectionProps { saveFolderTooltipContent: string; loadingSaveFolder: boolean; saveFolderPath: string | null; - onChangeExecutableLocation: () => Promise; + onOpenExecPicker: () => void; onClearExecutablePath: () => Promise; onOpenSaveFolder: () => Promise; } @@ -240,7 +239,7 @@ function ExecutableSection({ saveFolderTooltipContent, loadingSaveFolder, saveFolderPath, - onChangeExecutableLocation, + onOpenExecPicker, onClearExecutablePath, onOpenSaveFolder, }: Readonly) { @@ -260,7 +259,7 @@ function ExecutableSection({
void onChangeExecutableLocation() }} + actions={{ primary: () => onOpenExecPicker() }} asChild > @@ -296,7 +295,7 @@ function ExecutableSection({ focusId={GAME_LAUNCH_SETTINGS_EXEC_PATH_SELECT_ID} variant="secondary" icon={} - onClick={() => void onChangeExecutableLocation()} + onClick={() => onOpenExecPicker()} focusNavigationOverrides={{ left: { type: "item", @@ -520,6 +519,10 @@ function LaunchOptionsSection({ ); } +const EXEC_FILTERS: FileFilter[] = [ + { name: "Game executable", extensions: ["exe", "lnk"] }, +]; + export function GameLaunchSettingsTab({ game, launchOptions, @@ -528,7 +531,7 @@ export function GameLaunchSettingsTab({ creatingSteamShortcut, steamShortcutExists, shouldShowCreateStartMenuShortcut, - onChangeExecutableLocation, + onProcessExecPath, onClearExecutablePath, onOpenSaveFolder, onChangeLaunchOptions, @@ -539,11 +542,14 @@ export function GameLaunchSettingsTab({ onDeleteSteamShortcut, onSelectDisc, onToggleDontAskDiscSelection, - onAddDiscFile, + onProcessDiscPath, onRemoveSelectedDisc, onRemoveAllDiscs, }: Readonly) { const { t } = useTranslation("game_details"); + const [execPickerOpen, setExecPickerOpen] = useState(false); + const [discPickerOpen, setDiscPickerOpen] = useState(false); + const [discFilters, setDiscFilters] = useState([]); const isCustomGame = game.shop === "custom"; const discs = game.discs ?? []; const selectedDisc = @@ -558,48 +564,107 @@ export function GameLaunchSettingsTab({ t ); + const handleExecPicked = useCallback( + (path: string) => { + setExecPickerOpen(false); + void onProcessExecPath(path); + }, + [onProcessExecPath] + ); + + const handleExecPickerClose = useCallback(() => { + setExecPickerOpen(false); + }, []); + + const handleOpenExecPicker = useCallback(() => { + setExecPickerOpen(true); + }, []); + + const handleDiscPicked = useCallback( + (path: string) => { + setDiscPickerOpen(false); + void onProcessDiscPath(path); + }, + [onProcessDiscPath] + ); + + const handleDiscPickerClose = useCallback(() => { + setDiscPickerOpen(false); + }, []); + + const handleOpenDiscPicker = useCallback(async () => { + const system = platformToSystem(game.platform); + const extensions = system + ? await globalThis.window.electron.getEmulatorRomExtensions(system) + : ["*"]; + + setDiscFilters([ + { name: t("rom_file"), extensions }, + { name: t("all_files"), extensions: ["*"] }, + ]); + setDiscPickerOpen(true); + }, [game.platform, t]); + return ( - - {game.shop === "launchbox" ? ( - + + {game.shop === "launchbox" ? ( + + ) : ( + + )} + + - ) : ( - - )} - - + + - - + ); } diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts b/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts index 5f5eb1e5a..f8364223d 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts +++ b/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts @@ -2,7 +2,7 @@ import type { LibraryGame } from "@types"; import type { ChangeEvent } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { platformToSystem } from "@renderer/helpers"; + import { useBigPictureToast } from "../../../../hooks"; import { applyClassicsDiscUpdate, @@ -303,24 +303,10 @@ export function useGameSettingsModalState({ } }, [game, gameTitle, saveGameTitle, showErrorToast, t, updatingGameTitle]); - const handleSelectCustomizationAsset = useCallback( - async (assetType: CustomAssetType) => { + const handleProcessAssetPath = useCallback( + async (sourcePath: string, assetType: CustomAssetType) => { if (!game) return; - const { filePaths } = await globalThis.window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { - name: "Image files", - extensions: ["jpg", "jpeg", "png", "gif", "webp"], - }, - ], - }); - - const sourcePath = filePaths?.[0]; - - if (!sourcePath) return; - try { const copiedAssetUrl = await globalThis.window.electron.copyCustomGameAsset( @@ -382,64 +368,33 @@ export function useGameSettingsModalState({ }; }, [game, launchOptions, persistLaunchOptions, visible]); - const getDownloadsPath = useCallback(async () => { - const userPreferences = await globalThis.window.electron - .getUserPreferences() - .catch(() => null); - - return ( - userPreferences?.downloadsPath ?? - (await globalThis.window.electron.getDefaultDownloadsPath()) - ); - }, []); - - const selectGameExecutable = useCallback(async () => { - const downloadsPath = await getDownloadsPath(); - const { filePaths } = await globalThis.window.electron.showOpenDialog({ - properties: ["openFile"], - defaultPath: downloadsPath, - filters: [ - { - name: "Game executable", - extensions: ["exe", "lnk"], - }, - ], - }); - - if (filePaths && filePaths.length > 0) { - return filePaths[0]; - } - - return null; - }, [getDownloadsPath]); - - const handleChangeExecutableLocation = useCallback(async () => { - if (!game) return; + const handleProcessExecPath = useCallback( + async (path: string) => { + if (!game) return; - const path = await selectGameExecutable(); - if (!path) return; + const gameUsingPath = + await globalThis.window.electron.verifyExecutablePathInUse(path); - const gameUsingPath = - await globalThis.window.electron.verifyExecutablePathInUse(path); + if ( + gameUsingPath && + (gameUsingPath.objectId !== game.objectId || + gameUsingPath.shop !== game.shop) + ) { + showErrorToast( + t("executable_path_in_use", { game: gameUsingPath.title }) + ); + return; + } - if ( - gameUsingPath && - (gameUsingPath.objectId !== game.objectId || - gameUsingPath.shop !== game.shop) - ) { - showErrorToast( - t("executable_path_in_use", { game: gameUsingPath.title }) + await globalThis.window.electron.updateExecutablePath( + game.shop, + game.objectId, + path ); - return; - } - - await globalThis.window.electron.updateExecutablePath( - game.shop, - game.objectId, - path - ); - await updateGame(); - }, [game, selectGameExecutable, showErrorToast, t, updateGame]); + await updateGame(); + }, + [game, showErrorToast, t, updateGame] + ); const handleClearExecutablePath = useCallback(async () => { if (!game) return; @@ -583,23 +538,12 @@ export function useGameSettingsModalState({ [game, updateClassicsDisc] ); - const handleAddDiscFile = useCallback(async () => { - if (!game) return; - - const system = platformToSystem(game.platform); - const extensions = system - ? await globalThis.window.electron.getEmulatorRomExtensions(system) - : ["*"]; - const result = await globalThis.window.electron.showOpenDialog({ - properties: ["openFile"], - filters: [ - { name: t("rom_file"), extensions }, - { name: t("all_files"), extensions: ["*"] }, - ], - }); - if (result.canceled || !result.filePaths[0]) return; - await addDiscFromPath(result.filePaths[0]); - }, [addDiscFromPath, game, t]); + const handleProcessDiscPath = useCallback( + async (path: string) => { + await addDiscFromPath(path); + }, + [addDiscFromPath] + ); const handleRemoveSelectedDisc = useCallback(async () => { if (!game || !selectedDisc) return; @@ -666,7 +610,7 @@ export function useGameSettingsModalState({ steamShortcutExists, shouldShowCreateStartMenuShortcut: globalThis.window.electron.platform === "win32", - onChangeExecutableLocation: handleChangeExecutableLocation, + onProcessExecPath: handleProcessExecPath, onClearExecutablePath: handleClearExecutablePath, onOpenSaveFolder: handleOpenSaveFolder, onChangeLaunchOptions: setLaunchOptions, @@ -677,22 +621,22 @@ export function useGameSettingsModalState({ onDeleteSteamShortcut: handleDeleteSteamShortcut, onSelectDisc: handleSelectDisc, onToggleDontAskDiscSelection: handleToggleDontAskDiscSelection, - onAddDiscFile: handleAddDiscFile, + onProcessDiscPath: handleProcessDiscPath, onRemoveSelectedDisc: handleRemoveSelectedDisc, onRemoveAllDiscs: handleRemoveAllDiscs, } satisfies GameLaunchSettingsProps; }, [ creatingSteamShortcut, game, - handleAddDiscFile, handleBlurLaunchOptions, - handleChangeExecutableLocation, + handleProcessExecPath, handleClearExecutablePath, handleClearLaunchOptions, handleCreateShortcut, handleCreateSteamShortcut, handleDeleteSteamShortcut, handleOpenSaveFolder, + handleProcessDiscPath, handleRemoveAllDiscs, handleRemoveSelectedDisc, handleSelectDisc, @@ -712,7 +656,7 @@ export function useGameSettingsModalState({ updatingGameTitle, onChangeGameTitle: handleChangeGameTitle, onBlurGameTitle: handleBlurGameTitle, - onSelectAsset: handleSelectCustomizationAsset, + onProcessAssetPath: handleProcessAssetPath, onClearAsset: handleClearCustomizationAsset, } satisfies GameCustomizationSettingsProps; }, [ @@ -721,7 +665,7 @@ export function useGameSettingsModalState({ handleBlurGameTitle, handleChangeGameTitle, handleClearCustomizationAsset, - handleSelectCustomizationAsset, + handleProcessAssetPath, updatingGameTitle, ]); From 942d568bce94e4f234e96af1a211dd109aa94ca4 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Mon, 22 Jun 2026 17:26:14 -0300 Subject: [PATCH 07/14] refactor: enhance game settings modal components with dynamic filters and state management Updated the GameCompatibilitySettingsTab to include a dynamic initial path for the wine prefix selection. Refactored the GameLaunchSettingsTab to implement platform-specific executable filters, improving the user experience when selecting game executables. Adjusted the GameCustomizationSettingsTab to use explicit image file extensions, ensuring clarity and consistency in asset selection. --- .../game-settings-modal/compatibility-tab.tsx | 10 +++++-- .../game-settings-modal/customization-tab.tsx | 7 ++--- .../game/game-settings-modal/launch-tab.tsx | 28 +++++++++++++------ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx index efbbdde93..eec8d6e43 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx @@ -85,6 +85,9 @@ export function GameCompatibilitySettingsTab({ game.autoRunMangohud ?? false ); const [winePickerOpen, setWinePickerOpen] = useState(false); + const [winePickerInitialPath, setWinePickerInitialPath] = useState< + string | undefined + >(); useEffect(() => { setSelectedProtonPath(game.protonPath ?? ""); @@ -151,9 +154,11 @@ export function GameCompatibilitySettingsTab({ return options; }, [protonVersions, t, getProtonSourceDescription]); - const handleSelectWinePrefix = useCallback(() => { + const handleSelectWinePrefix = useCallback(async () => { + const defaultPath = await electron.getDefaultWinePrefixSelectionPath(); + setWinePickerInitialPath(winePrefixPath ?? defaultPath ?? undefined); setWinePickerOpen(true); - }, []); + }, [electron, winePrefixPath]); const handleWinePrefixPicked = useCallback( async (path: string) => { @@ -345,6 +350,7 @@ export function GameCompatibilitySettingsTab({ onClose={() => setWinePickerOpen(false)} onSelect={handleWinePrefixPicked} title={t("wine_prefix")} + initialPath={winePickerInitialPath} selectDirectory /> diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/customization-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/customization-tab.tsx index 8b7fc3a83..cb1687e9f 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/customization-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/customization-tab.tsx @@ -13,10 +13,7 @@ import { type FileFilter, VerticalFocusGroup, } from "../../../common"; -import { - imageExtensions, - resolvePreferredGameAssets, -} from "../../../../helpers"; +import { resolvePreferredGameAssets } from "../../../../helpers"; import { SettingsSection } from "../../../../pages/settings/settings-section"; import "./customization-tab.scss"; @@ -120,7 +117,7 @@ function getFallbackPreviewState( } const ASSET_FILTERS: FileFilter[] = [ - { name: "Image files", extensions: [...imageExtensions] }, + { name: "Image files", extensions: ["jpg", "jpeg", "png", "gif", "webp"] }, ]; export function GameCustomizationSettingsTab({ diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx index b7272ce75..f5bf517b6 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx @@ -519,9 +519,24 @@ function LaunchOptionsSection({ ); } -const EXEC_FILTERS: FileFilter[] = [ - { name: "Game executable", extensions: ["exe", "lnk"] }, -]; +function getExecFilters(platform: string): FileFilter[] { + if (platform === "linux") { + return [ + { + name: "Game executable", + extensions: ["AppImage", "sh", "x86_64", "x86", "run", "bin"], + }, + ]; + } + + if (platform === "darwin") { + return [{ name: "Game executable", extensions: ["app"] }]; + } + + return [ + { name: "Game executable", extensions: ["exe", "lnk", "bat", "cmd"] }, + ]; +} export function GameLaunchSettingsTab({ game, @@ -598,10 +613,7 @@ export function GameLaunchSettingsTab({ ? await globalThis.window.electron.getEmulatorRomExtensions(system) : ["*"]; - setDiscFilters([ - { name: t("rom_file"), extensions }, - { name: t("all_files"), extensions: ["*"] }, - ]); + setDiscFilters([{ name: t("rom_file"), extensions }]); setDiscPickerOpen(true); }, [game.platform, t]); @@ -655,7 +667,7 @@ export function GameLaunchSettingsTab({ onClose={handleExecPickerClose} onSelect={handleExecPicked} title={t("executable_section_title")} - filters={EXEC_FILTERS} + filters={getExecFilters(globalThis.window.electron.platform)} /> Date: Thu, 2 Jul 2026 10:41:12 -0300 Subject: [PATCH 08/14] feat: enhance FileExplorerModal with focus management and input handling - Introduced helper functions for managing focus IDs in the FileExplorerModal. - Updated the modal to utilize the new focus management logic for improved accessibility. - Refactored input handling to streamline the path input display and interaction. - Enhanced styles for better layout and responsiveness in the file explorer modal. --- .../common/file-explorer-modal/index.tsx | 76 +++++++++++++------ .../common/file-explorer-modal/styles.scss | 13 +++- .../file-explorer-modal/use-file-explorer.ts | 48 +----------- .../game/game-settings-modal/launch-tab.tsx | 6 +- src/main/events/misc/file-system.ts | 48 +++++++++++- 5 files changed, 116 insertions(+), 75 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index baa91c737..2ad00c776 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -7,7 +7,6 @@ import { } from "@phosphor-icons/react"; import { Modal } from "../modal"; import { VerticalFocusGroup } from "../vertical-focus-group"; -import { FocusItem } from "../focus-item"; import { EmptyState } from "../empty-state"; import { Skeleton } from "../skeleton"; import { getEntryIcon } from "../../../helpers"; @@ -16,12 +15,41 @@ import { useFileExplorer, type FileExplorerModalProps, } from "./use-file-explorer"; +import { FocusItem } from "../focus-item"; export { type FileExplorerModalProps } from "./use-file-explorer"; export { type FileFilter } from "./utils"; +function getDriveFocusId(drive: string) { + return `file-explorer-drive-${drive}`; +} + +function getEntryFocusId(path: string) { + return `file-explorer-entry-${path}`; +} + +function getInitialFocusId(vm: ReturnType) { + const firstDrive = vm.drives[0]; + const firstEntry = vm.filteredEntries[0]; + + if (vm.showSelectThisDir) { + return "file-explorer-select-dir"; + } + + if (vm.showDriveList && firstDrive) { + return getDriveFocusId(firstDrive); + } + + if (firstEntry) { + return getEntryFocusId(firstEntry.path); + } + + return undefined; +} + export function FileExplorerModal(props: Readonly) { const vm = useFileExplorer(props); + const initialFocusId = getInitialFocusId(vm); return ( ) { closeOnB={false} closeOnEscape={false} className="file-explorer-modal" + initialFocusId={initialFocusId} >
{vm.isLoading && ( @@ -52,34 +81,31 @@ export function FileExplorerModal(props: Readonly) {
)} + {!vm.isLoading && !vm.error && ( +
+ + + +
+ )} + {!vm.isLoading && !vm.error && ( -
- vm.pathInputRef.current?.focus() }} - > - vm.setPathInputValue(e.target.value)} - onKeyDown={vm.handlePathInputKeyDown} - /> - - - -
- {vm.showSelectThisDir && ( ) { {vm.drives.map((drive) => ( vm.navigateToDrive(drive) }} asChild > @@ -136,6 +163,7 @@ export function FileExplorerModal(props: Readonly) { {vm.filteredEntries.map((entry) => ( vm.handleEntrySelect(entry) }} asChild > diff --git a/src/big-picture/src/components/common/file-explorer-modal/styles.scss b/src/big-picture/src/components/common/file-explorer-modal/styles.scss index 8baa3a600..b8c840fbf 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/styles.scss +++ b/src/big-picture/src/components/common/file-explorer-modal/styles.scss @@ -1,5 +1,12 @@ .file-explorer-modal { max-height: min(100%, calc(100vh - 4rem), 60vh); + + .modal__content { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + } } .file-explorer { @@ -14,10 +21,7 @@ display: flex; align-items: center; width: 100%; - - > [data-focus-wrapper] { - width: 100%; - } + pointer-events: none; } &__path-input { @@ -33,6 +37,7 @@ background-color: var(--background); color: var(--text); transition: border-color 0.2s ease-in-out; + pointer-events: none; &:hover { border-color: var(--secondary-hover); diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index 81d30f023..b20d0c5b9 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -1,12 +1,4 @@ -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, - type KeyboardEvent, -} from "react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useNavigationScreenActions } from "../../../hooks"; import { type DirectoryEntry } from "../../../helpers"; import { @@ -85,8 +77,6 @@ export function useFileExplorer({ const [drives, setDrives] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [pathInputValue, setPathInputValue] = useState(""); - const pathInputRef = useRef(null); const generatedId = useId(); const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; @@ -107,10 +97,6 @@ export function useFileExplorer({ }; }, [visible, initialPath]); - useEffect(() => { - setPathInputValue(currentPath); - }, [currentPath]); - useEffect(() => { if (!visible) { setEntries([]); @@ -187,11 +173,11 @@ export function useFileExplorer({ }, [entries, allowedExtensions, drives, selectDirectory, currentPath]); const handleBPress = useCallback(() => { - if (!currentPath) return onClose(); - const parent = getParentPath(currentPath); if (parent) return setCurrentPath(parent); + if (!currentPath) return onClose(); + if (drives.length > 0) { setCurrentPath(""); return; @@ -225,30 +211,6 @@ export function useFileExplorer({ onSelect(currentPath); }, [currentPath, onSelect]); - const handlePathEnter = useCallback(async () => { - const target = pathInputValue.trim(); - if (!target) return; - - try { - const info = await globalThis.window.electron.getPathInfo(target); - if (info.exists && info.isDirectory) { - setCurrentPath(target); - } else setPathInputValue(currentPath); - } catch { - setPathInputValue(currentPath); - } - }, [pathInputValue, currentPath]); - - const handlePathInputKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handlePathEnter(); - } - }, - [handlePathEnter] - ); - const hasParent = Boolean( (currentPath && getParentPath(currentPath)) || (currentPath && drives.length > 0) @@ -270,9 +232,6 @@ export function useFileExplorer({ return { currentPath, - pathInputValue, - setPathInputValue, - pathInputRef, fileListRegionId, isLoading, error, @@ -289,7 +248,6 @@ export function useFileExplorer({ hasParent, handleEntrySelect, handleSelectThisDirectory, - handlePathInputKeyDown, goToParent, navigateToDrive, }; diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx index 77ee6fef9..fc89cbb65 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/launch-tab.tsx @@ -1,5 +1,9 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react"; -import { getSkuRegion, getSkuRegionFlag, type SkuRegion } from "@renderer/helpers"; +import { + getSkuRegion, + getSkuRegionFlag, + type SkuRegion, +} from "@renderer/helpers"; import type { LibraryGame, ShortcutLocation } from "@types"; import { DiscIcon } from "@phosphor-icons/react"; import { FolderOpen, HardDrive, Monitor, Trash } from "lucide-react"; diff --git a/src/main/events/misc/file-system.ts b/src/main/events/misc/file-system.ts index 005bb5a2c..70476cfa5 100644 --- a/src/main/events/misc/file-system.ts +++ b/src/main/events/misc/file-system.ts @@ -1,3 +1,4 @@ +import { app } from "electron"; import { access, readdir, stat } from "node:fs/promises"; import { join } from "node:path"; import { platform } from "node:os"; @@ -18,13 +19,58 @@ export interface PathInfo { isFile: boolean; } +function getReleaseRendererOrigin(): string | null { + const subdomain = import.meta.env.MAIN_VITE_LAUNCHER_SUBDOMAIN; + if (!subdomain) return null; + + try { + return new URL( + `https://release-v${app.getVersion().replaceAll(".", "-")}.${subdomain}` + ).origin; + } catch { + return null; + } +} + +function getDevRendererOrigin(): string | null { + const rendererUrl = process.env["ELECTRON_RENDERER_URL"]; + if (!rendererUrl) return null; + + try { + return new URL(rendererUrl).origin; + } catch { + return null; + } +} + +function isTrustedRendererUrl(url: string): boolean { + let parsedUrl: URL; + + try { + parsedUrl = new URL(url); + } catch { + return false; + } + + if (parsedUrl.protocol === "app:" || parsedUrl.protocol === "file:") { + return true; + } + + const trustedOrigins = [ + getDevRendererOrigin(), + getReleaseRendererOrigin(), + ].filter((origin): origin is string => Boolean(origin)); + + return trustedOrigins.includes(parsedUrl.origin); +} + function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void { if (!event.senderFrame) { throw new Error("Unauthorized IPC sender"); } const url = event.senderFrame.url; - if (!url.startsWith("app://") && !url.startsWith("file://")) { + if (!isTrustedRendererUrl(url)) { throw new Error("Unauthorized IPC sender"); } } From bb9e7c87c415513d6ddfbf7dc9d23e28d947b63f Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 11:48:47 -0300 Subject: [PATCH 09/14] feat: integrate i18n support in FileExplorerModal and useFileExplorer - Added translation support using `useTranslation` for user-facing strings in FileExplorerModal. - Updated placeholder text and labels in useFileExplorer to utilize translation keys. - Enhanced error handling messages with localized strings for better user experience. - Added new translation keys to the English and other locale files for consistency across languages. --- .../common/file-explorer-modal/index.tsx | 4 +- .../file-explorer-modal/use-file-explorer.ts | 65 ++++++++++++------- .../common/file-explorer-modal/utils.ts | 36 +++++++--- .../use-game-settings-modal-state.ts | 21 ++++-- .../src/helpers/file-entry-icon.tsx | 1 - .../src/locales/en/translation.json | 10 +++ .../src/locales/es/translation.json | 10 +++ .../src/locales/pt-BR/translation.json | 10 +++ .../src/locales/ru/translation.json | 10 +++ .../settings/download-directories-section.tsx | 61 +++++++++++------ src/main/events/misc/file-system.ts | 37 +++++++++-- 11 files changed, 200 insertions(+), 65 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index 2ad00c776..981587c14 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -5,6 +5,7 @@ import { FolderIcon, FolderOpenIcon, } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; import { Modal } from "../modal"; import { VerticalFocusGroup } from "../vertical-focus-group"; import { EmptyState } from "../empty-state"; @@ -48,6 +49,7 @@ function getInitialFocusId(vm: ReturnType) { } export function FileExplorerModal(props: Readonly) { + const { t } = useTranslation("big_picture"); const vm = useFileExplorer(props); const initialFocusId = getInitialFocusId(vm); @@ -120,7 +122,7 @@ export function FileExplorerModal(props: Readonly) { - Select this directory + {t("file_explorer_select_this_directory")} )} diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index b20d0c5b9..6fe9d3d80 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useNavigationScreenActions } from "../../../hooks"; -import { type DirectoryEntry } from "../../../helpers"; +import type { DirectoryEntry } from "../../../helpers"; import { getParentPath, matchesFilters, @@ -9,28 +10,21 @@ import { } from "./utils"; const SKELETON_COUNT = 6; -const PATH_INPUT_PLACEHOLDER = "Select a location"; -const DRIVES_LABEL = "Drives"; -const EMPTY_FOLDER_TITLE = "This folder is empty"; -const EMPTY_DIRECTORY_CHOICE_TITLE = "No folders found"; - -const ERROR_MESSAGES: Record = { - EACCES: "You don't have permission to open this location.", - ENOENT: "This location doesn't exist.", - ENOTDIR: "This is not a directory.", -}; - -function getErrorMessage(err: unknown): string { +function getErrorMessage( + err: unknown, + errorMessages: Record, + fallbackMessage: string +): string { const code = err instanceof Error && "code" in err ? (err as Record).code : undefined; if (typeof code === "string") { - return ERROR_MESSAGES[code] ?? "This location can't be opened."; + return errorMessages[code] ?? fallbackMessage; } - return "This location can't be opened."; + return fallbackMessage; } export interface FileExplorerModalProps { @@ -72,6 +66,7 @@ export function useFileExplorer({ filters, selectDirectory = false, }: Readonly) { + const { t } = useTranslation("big_picture"); const [currentPath, setCurrentPath] = useState(""); const [entries, setEntries] = useState([]); const [drives, setDrives] = useState([]); @@ -79,6 +74,19 @@ export function useFileExplorer({ const [error, setError] = useState(null); const generatedId = useId(); const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; + const pathInputPlaceholder = t("file_explorer_path_placeholder"); + const drivesLabel = t("file_explorer_drives"); + const emptyFolderTitle = t("file_explorer_empty_folder"); + const emptyDirectoryChoiceTitle = t("file_explorer_empty_directory"); + const fileExplorerErrorFallback = t("file_explorer_error_default"); + const fileExplorerErrorMessages = useMemo( + () => ({ + EACCES: t("file_explorer_error_eacces"), + ENOENT: t("file_explorer_error_enoent"), + ENOTDIR: t("file_explorer_error_enotdir"), + }), + [t] + ); useEffect(() => { if (!visible) { @@ -122,7 +130,13 @@ export function useFileExplorer({ if (!cancelled) setEntries(result); } catch (err) { if (!cancelled) { - setError(getErrorMessage(err)); + setError( + getErrorMessage( + err, + fileExplorerErrorMessages, + fileExplorerErrorFallback + ) + ); setEntries([]); } } finally { @@ -137,7 +151,12 @@ export function useFileExplorer({ return () => { cancelled = true; }; - }, [visible, currentPath]); + }, [ + visible, + currentPath, + fileExplorerErrorFallback, + fileExplorerErrorMessages, + ]); useEffect(() => { if (!visible || currentPath) { @@ -152,7 +171,7 @@ export function useFileExplorer({ const result = await globalThis.window.electron.listDrives(); if (!cancelled) setDrives(result); } catch { - // Drives failed to load — user can type a path manually + // Drives failed to load — the explorer can still use the current path. } }; @@ -223,7 +242,7 @@ export function useFileExplorer({ if (drives.length > 0) setCurrentPath(""); }, [currentPath, drives.length]); - const showSelectThisDir = selectDirectory && currentPath; + const showSelectThisDir = Boolean(selectDirectory && currentPath); const showDriveList = !currentPath && drives.length > 0; const navigateToDrive = useCallback((drive: string) => { @@ -238,11 +257,9 @@ export function useFileExplorer({ drives, filteredEntries, SKELETON_COUNT, - PATH_INPUT_PLACEHOLDER, - DRIVES_LABEL, - emptyTitle: selectDirectory - ? EMPTY_DIRECTORY_CHOICE_TITLE - : EMPTY_FOLDER_TITLE, + PATH_INPUT_PLACEHOLDER: pathInputPlaceholder, + DRIVES_LABEL: drivesLabel, + emptyTitle: selectDirectory ? emptyDirectoryChoiceTitle : emptyFolderTitle, showSelectThisDir, showDriveList, hasParent, diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts index 088284a88..2cf0d3ba9 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/utils.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -1,5 +1,5 @@ -import type { DirectoryEntry } from "../../../helpers"; import { formatBytes } from "@shared"; +import type { DirectoryEntry } from "../../../helpers"; export interface FileFilter { name: string; @@ -7,21 +7,39 @@ export interface FileFilter { } const WINDOWS_DRIVE_RE = /^[A-Za-z]:$/; +const WINDOWS_ROOT_RE = /^[A-Za-z]:[\\/]?$/; export function getParentPath(path: string): string | null { if (!path) return null; - const normalized = path.replaceAll("\\", "/").replace(/\/$/, ""); + const isWindowsPath = path.includes("\\") || /^[A-Za-z]:([\\/]|$)/.test(path); + + if (isWindowsPath) { + const trimmedPath = path.replace(/[\\/]+$/, ""); + + if (WINDOWS_ROOT_RE.test(path) || WINDOWS_DRIVE_RE.test(trimmedPath)) { + return null; + } + + const lastSeparator = Math.max( + trimmedPath.lastIndexOf("\\"), + trimmedPath.lastIndexOf("/") + ); + + if (lastSeparator === -1) return null; + + const parent = trimmedPath.slice(0, lastSeparator).replaceAll("/", "\\"); + return WINDOWS_DRIVE_RE.test(parent) ? `${parent}\\` : parent || null; + } + + const normalized = path.replace(/\/$/, ""); if (normalized === "/") return null; - if (WINDOWS_DRIVE_RE.test(normalized)) return null; const lastSlash = normalized.lastIndexOf("/"); if (lastSlash === -1) return null; - const parent = normalized.substring(0, lastSlash) || "/"; - - return WINDOWS_DRIVE_RE.test(parent) ? parent + "/" : parent; + return normalized.substring(0, lastSlash) || "/"; } export function getEntryMeta(entry: DirectoryEntry): string { @@ -33,9 +51,11 @@ export function normalizeFilters(filters?: FileFilter[]): Set | null { if (!filters || filters.length === 0) return null; const allExtensions = filters.flatMap((f) => f.extensions); - if (allExtensions.includes("*")) return null; + const specificExtensions = allExtensions.filter((ext) => ext !== "*"); + + if (specificExtensions.length === 0) return null; - return new Set(allExtensions.map((ext) => ext.toLowerCase())); + return new Set(specificExtensions.map((ext) => ext.toLowerCase())); } export function matchesFilters( diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts b/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts index c18f26f98..98b8f0fc0 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts +++ b/src/big-picture/src/components/pages/game/game-settings-modal/use-game-settings-modal-state.ts @@ -32,9 +32,7 @@ interface UseGameSettingsModalStateResult { } type CustomAssetType = "icon" | "logo" | "hero"; -const ASSET_PICKER_FILTERS: FileFilter[] = [ - { name: "Image files", extensions: ["jpg", "jpeg", "png", "gif", "webp"] }, -]; +const IMAGE_FILE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const; export function useGameSettingsModalState({ game, @@ -42,7 +40,7 @@ export function useGameSettingsModalState({ updateGame, refreshGameDetails, }: Readonly): UseGameSettingsModalStateResult { - const { t } = useTranslation("game_details"); + const { t } = useTranslation(["game_details", "sidebar"]); const { showErrorToast, showSuccessToast } = useBigPictureToast(); const [gameTitle, setGameTitle] = useState(""); const [launchOptions, setLaunchOptions] = useState(""); @@ -98,7 +96,7 @@ export function useGameSettingsModalState({ }, [game?.shop, getDownloadsPath, visible]); useEffect(() => { - if (!visible || !game || game.shop !== "launchbox") { + if (!visible || game?.shop !== "launchbox") { setDiscPickerFilters([]); return; } @@ -438,6 +436,16 @@ export function useGameSettingsModalState({ [t] ); + const assetPickerFilters = useMemo( + () => [ + { + name: t("edit_game_modal_image_filter", { ns: "sidebar" }), + extensions: [...IMAGE_FILE_EXTENSIONS], + }, + ], + [t] + ); + const handleProcessExecPath = useCallback( async (path: string) => { if (!game) return; @@ -730,7 +738,7 @@ export function useGameSettingsModalState({ game, gameTitle, updatingGameTitle, - assetPickerFilters: ASSET_PICKER_FILTERS, + assetPickerFilters, onChangeGameTitle: handleChangeGameTitle, onBlurGameTitle: handleBlurGameTitle, onProcessAssetPath: handleProcessAssetPath, @@ -739,6 +747,7 @@ export function useGameSettingsModalState({ }, [ game, gameTitle, + assetPickerFilters, handleBlurGameTitle, handleChangeGameTitle, handleClearCustomizationAsset, diff --git a/src/big-picture/src/helpers/file-entry-icon.tsx b/src/big-picture/src/helpers/file-entry-icon.tsx index be425cffa..4aff802ba 100644 --- a/src/big-picture/src/helpers/file-entry-icon.tsx +++ b/src/big-picture/src/helpers/file-entry-icon.tsx @@ -116,7 +116,6 @@ export const audioExtensions = new Set([ "umx", "ac3", "dts", - "mka", "tta", "wv", "ra", diff --git a/src/big-picture/src/locales/en/translation.json b/src/big-picture/src/locales/en/translation.json index e15dbc163..7ab315105 100644 --- a/src/big-picture/src/locales/en/translation.json +++ b/src/big-picture/src/locales/en/translation.json @@ -9,6 +9,16 @@ "account actions": "account actions", "Action": "Action", "Audio": "Audio", + "Select a location": "Select a location", + "Drives": "Drives", + "This folder is empty": "This folder is empty", + "No folders found": "No folders found", + "You don't have permission to open this location.": "You don't have permission to open this location.", + "This location doesn't exist.": "This location doesn't exist.", + "This is not a directory.": "This is not a directory.", + "This location can't be opened.": "This location can't be opened.", + "Select this directory": "Select this directory", + "Select Download Directory": "Select Download Directory", "Basic": "Basic", "Basic: image, title and description.": "Basic: image, title and description.", "Block checkbox active": "Block checkbox active", diff --git a/src/big-picture/src/locales/es/translation.json b/src/big-picture/src/locales/es/translation.json index 42c2e3269..d10815636 100644 --- a/src/big-picture/src/locales/es/translation.json +++ b/src/big-picture/src/locales/es/translation.json @@ -14,6 +14,16 @@ "Achievement preview": "Vista previa del logro", "Achievement unlocked": "Logro desbloqueado", "Action": "Acción", + "Select a location": "Selecciona una ubicación", + "Drives": "Unidades", + "This folder is empty": "Esta carpeta está vacía", + "No folders found": "No se encontraron carpetas", + "You don't have permission to open this location.": "No tienes permiso para abrir esta ubicación.", + "This location doesn't exist.": "Esta ubicación no existe.", + "This is not a directory.": "Esta ruta no es una carpeta.", + "This location can't be opened.": "No se puede abrir esta ubicación.", + "Select this directory": "Seleccionar esta carpeta", + "Select Download Directory": "Seleccionar directorio de descarga", "Add Directory": "Añadir directorio", "Add Friend": "Añadir amigo", "Add game": "Añadir juego", diff --git a/src/big-picture/src/locales/pt-BR/translation.json b/src/big-picture/src/locales/pt-BR/translation.json index 91582c25b..e24101cc5 100644 --- a/src/big-picture/src/locales/pt-BR/translation.json +++ b/src/big-picture/src/locales/pt-BR/translation.json @@ -14,6 +14,16 @@ "Achievement preview": "Prévia da conquista", "Achievement unlocked": "Conquista desbloqueada", "Action": "Ação", + "Select a location": "Selecione um local", + "Drives": "Unidades", + "This folder is empty": "Esta pasta está vazia", + "No folders found": "Nenhuma pasta encontrada", + "You don't have permission to open this location.": "Você não tem permissão para abrir este local.", + "This location doesn't exist.": "Este local não existe.", + "This is not a directory.": "Este caminho não é um diretório.", + "This location can't be opened.": "Não foi possível abrir este local.", + "Select this directory": "Selecionar esta pasta", + "Select Download Directory": "Selecionar diretório de download", "Add Directory": "Adicionar diretório", "Add Friend": "Adicionar amigo", "Add game": "Adicionar jogo", diff --git a/src/big-picture/src/locales/ru/translation.json b/src/big-picture/src/locales/ru/translation.json index 8c996f92a..ed0d3f1a5 100644 --- a/src/big-picture/src/locales/ru/translation.json +++ b/src/big-picture/src/locales/ru/translation.json @@ -14,6 +14,16 @@ "Achievement preview": "Предпросмотр достижения", "Achievement unlocked": "Достижение открыто", "Action": "Действие", + "Select a location": "Выберите расположение", + "Drives": "Диски", + "This folder is empty": "Эта папка пуста", + "No folders found": "Папки не найдены", + "You don't have permission to open this location.": "У вас нет прав для открытия этого расположения.", + "This location doesn't exist.": "Это расположение не существует.", + "This is not a directory.": "Этот путь не является папкой.", + "This location can't be opened.": "Не удалось открыть это расположение.", + "Select this directory": "Выбрать эту папку", + "Select Download Directory": "Выбрать папку загрузки", "Add Directory": "Добавить папку", "Add Friend": "Добавить друга", "Add game": "Добавить игру", diff --git a/src/big-picture/src/pages/settings/download-directories-section.tsx b/src/big-picture/src/pages/settings/download-directories-section.tsx index 57063ade3..61e5e62dc 100644 --- a/src/big-picture/src/pages/settings/download-directories-section.tsx +++ b/src/big-picture/src/pages/settings/download-directories-section.tsx @@ -1,7 +1,6 @@ import "./download-directories-section.scss"; import type { DiskUsage, UserPreferences } from "@types"; -import { DOWNLOAD_DIRECTORIES_DEFAULT_SELECT_ID } from "./settings-navigation"; import { MAX_DOWNLOAD_DIRECTORIES, MAX_OPTIONAL_DOWNLOAD_DIRECTORIES, @@ -25,6 +24,7 @@ import { useMemo, useState, } from "react"; +import { useTranslation } from "react-i18next"; import { Button, @@ -45,6 +45,7 @@ import { SettingsSection } from "./settings-section"; import { SETTINGS_HEADER_RETURN_TARGET, SETTINGS_SIDEBAR_RETURN_TARGET, + DOWNLOAD_DIRECTORIES_DEFAULT_SELECT_ID, } from "./settings-navigation"; interface DownloadDirectoriesSectionProps { @@ -232,26 +233,45 @@ function getDirectoryCardNavigationOverrides( const belowSlot = rowBelow ? getClosestVerticalNeighbor(slot, rowBelow) : null; + const blockTarget = { type: "block" as const }; + + let leftTarget: FocusOverrides["left"]; + if (previousSlot) { + leftTarget = getItemFocusTarget(focusIds[previousSlot.index]); + } else if (index === 0) { + leftTarget = SETTINGS_SIDEBAR_RETURN_TARGET; + } else { + leftTarget = blockTarget; + } + + let rightTarget: FocusOverrides["right"]; + if (nextSlot) { + rightTarget = getItemFocusTarget(focusIds[nextSlot.index]); + } else { + rightTarget = blockTarget; + } + + let upTarget: FocusOverrides["up"]; + if (aboveSlot) { + upTarget = getItemFocusTarget(focusIds[aboveSlot.index]); + } else { + upTarget = getItemFocusTarget( + getDirectoryCardControlUpTargetId(directoryCount, index) + ); + } + + let downTarget: FocusOverrides["down"]; + if (belowSlot) { + downTarget = getItemFocusTarget(focusIds[belowSlot.index]); + } else { + downTarget = undefined; + } return { - left: previousSlot - ? getItemFocusTarget(focusIds[previousSlot.index]) - : index === 0 - ? SETTINGS_SIDEBAR_RETURN_TARGET - : { type: "block" }, - right: nextSlot - ? getItemFocusTarget(focusIds[nextSlot.index]) - : { type: "block" }, - up: - aboveSlot != null - ? getItemFocusTarget(focusIds[aboveSlot.index]) - : getItemFocusTarget( - getDirectoryCardControlUpTargetId(directoryCount, index) - ), - down: - belowSlot != null - ? getItemFocusTarget(focusIds[belowSlot.index]) - : undefined, + left: leftTarget, + right: rightTarget, + up: upTarget, + down: downTarget, }; } @@ -295,6 +315,7 @@ function persistDownloadDirectoryPreferences( export function DownloadDirectoriesSection({ className, }: Readonly) { + const { t } = useTranslation("big_picture"); const userPreferences = useUserPreferences(); const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); const [diskUsageByPath, setDiskUsageByPath] = useState< @@ -655,7 +676,7 @@ export function DownloadDirectoriesSection({ visible={filePickerOpen} onClose={handleFilePickerClose} onSelect={handleFilePickerSelect} - title="Select Download Directory" + title={t("file_explorer_select_download_directory")} initialPath={resolvedDirectories?.defaultPath} selectDirectory /> diff --git a/src/main/events/misc/file-system.ts b/src/main/events/misc/file-system.ts index 70476cfa5..ae7b0b9c4 100644 --- a/src/main/events/misc/file-system.ts +++ b/src/main/events/misc/file-system.ts @@ -4,7 +4,9 @@ import { join } from "node:path"; import { platform } from "node:os"; import { registerEvent } from "../register-event"; -export interface DirectoryEntry { +const FILE_STAT_CONCURRENCY = 32; + +interface DirectoryEntry { name: string; path: string; isDirectory: boolean; @@ -13,7 +15,7 @@ export interface DirectoryEntry { size: number; } -export interface PathInfo { +interface PathInfo { exists: boolean; isDirectory: boolean; isFile: boolean; @@ -75,6 +77,29 @@ function assertTrustedSender(event: Electron.IpcMainInvokeEvent): void { } } +async function mapWithConcurrency( + items: TItem[], + limit: number, + mapper: (item: TItem) => Promise +): Promise { + const results: TResult[] = new Array(items.length); + let currentIndex = 0; + + const worker = async () => { + while (currentIndex < items.length) { + const nextIndex = currentIndex; + currentIndex += 1; + results[nextIndex] = await mapper(items[nextIndex]); + } + }; + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, () => worker()) + ); + + return results; +} + const readDirectory = async ( event: Electron.IpcMainInvokeEvent, dirPath: string @@ -83,8 +108,10 @@ const readDirectory = async ( const entries = await readdir(dirPath, { withFileTypes: true }); - const result: DirectoryEntry[] = await Promise.all( - entries.map(async (entry) => { + const result = await mapWithConcurrency( + entries, + FILE_STAT_CONCURRENCY, + async (entry): Promise => { const fullPath = join(dirPath, entry.name); const name = entry.name; const isMacApp = @@ -116,7 +143,7 @@ const readDirectory = async ( extension: ext, size, }; - }) + } ); const collator = new Intl.Collator(undefined, { From 2ab6aef12e2a5129d5bed6cfee6810768ae41897 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 12:53:52 -0300 Subject: [PATCH 10/14] refactor: improve file explorer logic and enhance localization support - Updated `useFileExplorer` to optimize drive loading logic and prevent unnecessary state updates. - Refactored `normalizeFilters` to return a structured object for better filter management. - Changed export of extension sets to constants for improved consistency. - Added new translation keys for file explorer functionality in multiple languages, ensuring all user-facing strings are properly localized. --- .../file-explorer-modal/use-file-explorer.ts | 6 +- .../common/file-explorer-modal/utils.ts | 27 +++++++-- .../src/helpers/file-entry-icon.tsx | 18 +++--- .../src/locales/en/translation.json | 22 +++---- .../src/locales/es/translation.json | 22 +++---- .../src/locales/fr/translation.json | 12 +++- .../src/locales/pt-BR/translation.json | 22 +++---- .../src/locales/ru/translation.json | 22 +++---- src/main/events/misc/file-system.ts | 58 +++++++++++++------ 9 files changed, 128 insertions(+), 81 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index 6fe9d3d80..e9b0f6418 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -159,11 +159,13 @@ export function useFileExplorer({ ]); useEffect(() => { - if (!visible || currentPath) { + if (!visible) { setDrives([]); return; } + if (currentPath || drives.length > 0) return; + let cancelled = false; const loadDrives = async () => { @@ -180,7 +182,7 @@ export function useFileExplorer({ return () => { cancelled = true; }; - }, [visible, currentPath]); + }, [visible, currentPath, drives.length]); const allowedExtensions = useMemo(() => normalizeFilters(filters), [filters]); diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts index 2cf0d3ba9..2e8acd413 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/utils.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -6,6 +6,11 @@ export interface FileFilter { extensions: string[]; } +interface NormalizedFilters { + specificExtensions: Set; + hasWildcard: boolean; +} + const WINDOWS_DRIVE_RE = /^[A-Za-z]:$/; const WINDOWS_ROOT_RE = /^[A-Za-z]:[\\/]?$/; @@ -47,25 +52,35 @@ export function getEntryMeta(entry: DirectoryEntry): string { return formatBytes(entry.size); } -export function normalizeFilters(filters?: FileFilter[]): Set | null { +export function normalizeFilters( + filters?: FileFilter[] +): NormalizedFilters | null { if (!filters || filters.length === 0) return null; const allExtensions = filters.flatMap((f) => f.extensions); + const hasWildcard = allExtensions.includes("*"); const specificExtensions = allExtensions.filter((ext) => ext !== "*"); - if (specificExtensions.length === 0) return null; + if (specificExtensions.length === 0 && !hasWildcard) return null; - return new Set(specificExtensions.map((ext) => ext.toLowerCase())); + return { + specificExtensions: new Set( + specificExtensions.map((ext) => ext.toLowerCase()) + ), + hasWildcard, + }; } export function matchesFilters( entry: DirectoryEntry, - allowedExtensions: Set | null, + filters: NormalizedFilters | null, directoryOnly: boolean ): boolean { if (directoryOnly && !entry.isDirectory) return false; if (entry.isDirectory) return true; - if (!allowedExtensions) return true; + if (!filters) return true; + if (filters.specificExtensions.size === 0) return filters.hasWildcard; + if (filters.specificExtensions.has(entry.extension)) return true; - return allowedExtensions.has(entry.extension); + return filters.hasWildcard && entry.extension === ""; } diff --git a/src/big-picture/src/helpers/file-entry-icon.tsx b/src/big-picture/src/helpers/file-entry-icon.tsx index 4aff802ba..3e5ae0bf0 100644 --- a/src/big-picture/src/helpers/file-entry-icon.tsx +++ b/src/big-picture/src/helpers/file-entry-icon.tsx @@ -48,7 +48,7 @@ const iconProps = { weight: "fill", } as const; -export const imageExtensions = new Set([ +const imageExtensions = new Set([ "jpg", "jpeg", "jpe", @@ -84,7 +84,7 @@ export const imageExtensions = new Set([ "srw", ]); -export const audioExtensions = new Set([ +const audioExtensions = new Set([ "mp3", "mp2", "mpa", @@ -121,7 +121,7 @@ export const audioExtensions = new Set([ "ra", ]); -export const videoExtensions = new Set([ +const videoExtensions = new Set([ "mp4", "m4v", "avi", @@ -149,7 +149,7 @@ export const videoExtensions = new Set([ "divx", ]); -export const archiveExtensions = new Set([ +const archiveExtensions = new Set([ "zip", "rar", "7z", @@ -185,7 +185,7 @@ export const archiveExtensions = new Set([ "gem", ]); -export const discExtensions = new Set([ +const discExtensions = new Set([ "iso", "bin", "cue", @@ -290,7 +290,7 @@ export const discExtensions = new Set([ "3dsx", ]); -export const scriptExtensions = new Set([ +const scriptExtensions = new Set([ "sh", "bash", "zsh", @@ -311,7 +311,7 @@ export const scriptExtensions = new Set([ "run", ]); -export const saveExtensions = new Set([ +const saveExtensions = new Set([ "mcr", "mcd", "mc", @@ -348,7 +348,7 @@ export const saveExtensions = new Set([ "fs", ]); -export const patchExtensions = new Set([ +const patchExtensions = new Set([ "ips", "bps", "ups", @@ -359,7 +359,7 @@ export const patchExtensions = new Set([ "aps", ]); -export const codeExtensions = new Set([ +const codeExtensions = new Set([ "go", "rs", "rb", diff --git a/src/big-picture/src/locales/en/translation.json b/src/big-picture/src/locales/en/translation.json index 7ab315105..2e399d2ff 100644 --- a/src/big-picture/src/locales/en/translation.json +++ b/src/big-picture/src/locales/en/translation.json @@ -9,16 +9,6 @@ "account actions": "account actions", "Action": "Action", "Audio": "Audio", - "Select a location": "Select a location", - "Drives": "Drives", - "This folder is empty": "This folder is empty", - "No folders found": "No folders found", - "You don't have permission to open this location.": "You don't have permission to open this location.", - "This location doesn't exist.": "This location doesn't exist.", - "This is not a directory.": "This is not a directory.", - "This location can't be opened.": "This location can't be opened.", - "Select this directory": "Select this directory", - "Select Download Directory": "Select Download Directory", "Basic": "Basic", "Basic: image, title and description.": "Basic: image, title and description.", "Block checkbox active": "Block checkbox active", @@ -759,6 +749,16 @@ "settings_diagnostics_position_top_right": "Top Right", "settings_diagnostics_position_bottom_left": "Bottom Left", "settings_diagnostics_position_bottom_center": "Bottom Center", - "settings_diagnostics_position_bottom_right": "Bottom Right" + "settings_diagnostics_position_bottom_right": "Bottom Right", + "file_explorer_path_placeholder": "Select a location", + "file_explorer_drives": "Drives", + "file_explorer_empty_folder": "This folder is empty", + "file_explorer_empty_directory": "No folders found", + "file_explorer_error_eacces": "You don't have permission to open this location.", + "file_explorer_error_enoent": "This location doesn't exist.", + "file_explorer_error_enotdir": "This is not a directory.", + "file_explorer_error_default": "This location can't be opened.", + "file_explorer_select_this_directory": "Select this directory", + "file_explorer_select_download_directory": "Select Download Directory" } } diff --git a/src/big-picture/src/locales/es/translation.json b/src/big-picture/src/locales/es/translation.json index d10815636..221b9a487 100644 --- a/src/big-picture/src/locales/es/translation.json +++ b/src/big-picture/src/locales/es/translation.json @@ -14,16 +14,6 @@ "Achievement preview": "Vista previa del logro", "Achievement unlocked": "Logro desbloqueado", "Action": "Acción", - "Select a location": "Selecciona una ubicación", - "Drives": "Unidades", - "This folder is empty": "Esta carpeta está vacía", - "No folders found": "No se encontraron carpetas", - "You don't have permission to open this location.": "No tienes permiso para abrir esta ubicación.", - "This location doesn't exist.": "Esta ubicación no existe.", - "This is not a directory.": "Esta ruta no es una carpeta.", - "This location can't be opened.": "No se puede abrir esta ubicación.", - "Select this directory": "Seleccionar esta carpeta", - "Select Download Directory": "Seleccionar directorio de descarga", "Add Directory": "Añadir directorio", "Add Friend": "Añadir amigo", "Add game": "Añadir juego", @@ -748,6 +738,16 @@ "settings_diagnostics_position_top_right": "Superior Derecha", "settings_diagnostics_position_bottom_left": "Inferior Izquierda", "settings_diagnostics_position_bottom_center": "Inferior Centro", - "settings_diagnostics_position_bottom_right": "Inferior Derecha" + "settings_diagnostics_position_bottom_right": "Inferior Derecha", + "file_explorer_path_placeholder": "Selecciona una ubicación", + "file_explorer_drives": "Unidades", + "file_explorer_empty_folder": "Esta carpeta está vacía", + "file_explorer_empty_directory": "No se encontraron carpetas", + "file_explorer_error_eacces": "No tienes permiso para abrir esta ubicación.", + "file_explorer_error_enoent": "Esta ubicación no existe.", + "file_explorer_error_enotdir": "Esta ruta no es una carpeta.", + "file_explorer_error_default": "No se puede abrir esta ubicación.", + "file_explorer_select_this_directory": "Seleccionar esta carpeta", + "file_explorer_select_download_directory": "Seleccionar directorio de descarga" } } diff --git a/src/big-picture/src/locales/fr/translation.json b/src/big-picture/src/locales/fr/translation.json index d8e12b511..2b4668d62 100644 --- a/src/big-picture/src/locales/fr/translation.json +++ b/src/big-picture/src/locales/fr/translation.json @@ -730,6 +730,16 @@ "settings_diagnostics_position_top_right": "En Haut à Droite", "settings_diagnostics_position_bottom_left": "En Bas à Gauche", "settings_diagnostics_position_bottom_center": "En Bas au Centre", - "settings_diagnostics_position_bottom_right": "En Bas à Droite" + "settings_diagnostics_position_bottom_right": "En Bas à Droite", + "file_explorer_path_placeholder": "Sélectionner un emplacement", + "file_explorer_drives": "Lecteurs", + "file_explorer_empty_folder": "Ce dossier est vide", + "file_explorer_empty_directory": "Aucun dossier trouvé", + "file_explorer_error_eacces": "Vous n'avez pas l'autorisation d'ouvrir cet emplacement.", + "file_explorer_error_enoent": "Cet emplacement n'existe pas.", + "file_explorer_error_enotdir": "Ce chemin n'est pas un dossier.", + "file_explorer_error_default": "Impossible d'ouvrir cet emplacement.", + "file_explorer_select_this_directory": "Sélectionner ce dossier", + "file_explorer_select_download_directory": "Sélectionner le dossier de téléchargement" } } diff --git a/src/big-picture/src/locales/pt-BR/translation.json b/src/big-picture/src/locales/pt-BR/translation.json index e24101cc5..0a4e322d9 100644 --- a/src/big-picture/src/locales/pt-BR/translation.json +++ b/src/big-picture/src/locales/pt-BR/translation.json @@ -14,16 +14,6 @@ "Achievement preview": "Prévia da conquista", "Achievement unlocked": "Conquista desbloqueada", "Action": "Ação", - "Select a location": "Selecione um local", - "Drives": "Unidades", - "This folder is empty": "Esta pasta está vazia", - "No folders found": "Nenhuma pasta encontrada", - "You don't have permission to open this location.": "Você não tem permissão para abrir este local.", - "This location doesn't exist.": "Este local não existe.", - "This is not a directory.": "Este caminho não é um diretório.", - "This location can't be opened.": "Não foi possível abrir este local.", - "Select this directory": "Selecionar esta pasta", - "Select Download Directory": "Selecionar diretório de download", "Add Directory": "Adicionar diretório", "Add Friend": "Adicionar amigo", "Add game": "Adicionar jogo", @@ -753,6 +743,16 @@ "settings_diagnostics_position_top_right": "Superior Direito", "settings_diagnostics_position_bottom_left": "Inferior Esquerdo", "settings_diagnostics_position_bottom_center": "Inferior Centro", - "settings_diagnostics_position_bottom_right": "Inferior Direito" + "settings_diagnostics_position_bottom_right": "Inferior Direito", + "file_explorer_path_placeholder": "Selecione um local", + "file_explorer_drives": "Unidades", + "file_explorer_empty_folder": "Esta pasta está vazia", + "file_explorer_empty_directory": "Nenhuma pasta encontrada", + "file_explorer_error_eacces": "Você não tem permissão para abrir este local.", + "file_explorer_error_enoent": "Este local não existe.", + "file_explorer_error_enotdir": "Este caminho não é um diretório.", + "file_explorer_error_default": "Não foi possível abrir este local.", + "file_explorer_select_this_directory": "Selecionar esta pasta", + "file_explorer_select_download_directory": "Selecionar diretório de download" } } diff --git a/src/big-picture/src/locales/ru/translation.json b/src/big-picture/src/locales/ru/translation.json index ed0d3f1a5..dd4c2eccf 100644 --- a/src/big-picture/src/locales/ru/translation.json +++ b/src/big-picture/src/locales/ru/translation.json @@ -14,16 +14,6 @@ "Achievement preview": "Предпросмотр достижения", "Achievement unlocked": "Достижение открыто", "Action": "Действие", - "Select a location": "Выберите расположение", - "Drives": "Диски", - "This folder is empty": "Эта папка пуста", - "No folders found": "Папки не найдены", - "You don't have permission to open this location.": "У вас нет прав для открытия этого расположения.", - "This location doesn't exist.": "Это расположение не существует.", - "This is not a directory.": "Этот путь не является папкой.", - "This location can't be opened.": "Не удалось открыть это расположение.", - "Select this directory": "Выбрать эту папку", - "Select Download Directory": "Выбрать папку загрузки", "Add Directory": "Добавить папку", "Add Friend": "Добавить друга", "Add game": "Добавить игру", @@ -767,6 +757,16 @@ "settings_diagnostics_position_top_right": "Сверху справа", "settings_diagnostics_position_bottom_left": "Снизу слева", "settings_diagnostics_position_bottom_center": "Снизу по центру", - "settings_diagnostics_position_bottom_right": "Снизу справа" + "settings_diagnostics_position_bottom_right": "Снизу справа", + "file_explorer_path_placeholder": "Выберите расположение", + "file_explorer_drives": "Диски", + "file_explorer_empty_folder": "Эта папка пуста", + "file_explorer_empty_directory": "Папки не найдены", + "file_explorer_error_eacces": "У вас нет прав для открытия этого расположения.", + "file_explorer_error_enoent": "Это расположение не существует.", + "file_explorer_error_enotdir": "Этот путь не является папкой.", + "file_explorer_error_default": "Не удалось открыть это расположение.", + "file_explorer_select_this_directory": "Выбрать эту папку", + "file_explorer_select_download_directory": "Выбрать папку загрузки" } } diff --git a/src/main/events/misc/file-system.ts b/src/main/events/misc/file-system.ts index ae7b0b9c4..43061c961 100644 --- a/src/main/events/misc/file-system.ts +++ b/src/main/events/misc/file-system.ts @@ -114,10 +114,26 @@ const readDirectory = async ( async (entry): Promise => { const fullPath = join(dirPath, entry.name); const name = entry.name; + const isSymbolicLink = entry.isSymbolicLink(); + + let stats: Awaited> | null = null; + if (isSymbolicLink || entry.isFile()) { + try { + stats = await stat(fullPath); + } catch { + // Path may not be accessible + } + } + + const isDirectoryCandidate = stats + ? stats.isDirectory() + : entry.isDirectory(); const isMacApp = - entry.isDirectory() && name.toLowerCase().endsWith(".app"); - const isDirectory = entry.isDirectory() && !isMacApp; - const isFile = entry.isFile() || isMacApp; + isDirectoryCandidate && name.toLowerCase().endsWith(".app"); + const isDirectory = isDirectoryCandidate && !isMacApp; + const isFile = stats + ? stats.isFile() || isMacApp + : entry.isFile() || isMacApp; let ext = ""; if (isFile && name.includes(".")) { @@ -127,11 +143,15 @@ const readDirectory = async ( let size = 0; if (isFile) { - try { - const stats = await stat(fullPath); + if (stats) { size = stats.size; - } catch { - // File may not be accessible + } else { + try { + const fileStats = await stat(fullPath); + size = fileStats.size; + } catch { + // File may not be accessible + } } } @@ -183,23 +203,23 @@ const getPathInfo = async ( } }; -const listDrives = async (): Promise => { - if (platform() === "win32") { - const drives: string[] = []; +const listDrives = async ( + event: Electron.IpcMainInvokeEvent +): Promise => { + assertTrustedSender(event); - for (let i = 0; i < 26; i++) { + if (platform() === "win32") { + const driveChecks = Array.from({ length: 26 }, (_, i) => { const letter = String.fromCodePoint(65 + i); const root = `${letter}:\\`; - try { - await access(root); - drives.push(root); - } catch { - // Drive doesn't exist - } - } + return access(root) + .then(() => root) + .catch(() => null); + }); - return drives; + const drives = await Promise.all(driveChecks); + return drives.filter((drive): drive is string => drive !== null); } return ["/"]; From 45b83182840fda464ca0f8c8d3d6b7e8becf5759 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 13:13:34 -0300 Subject: [PATCH 11/14] feat: add filter tabs to FileExplorerModal for improved file management - Introduced filter tabs in FileExplorerModal to allow users to easily switch between different file filters. - Updated `useFileExplorer` to manage active filter state and provide filter tab items. - Enhanced localization by adding new translation keys for filter labels in multiple languages. - Improved styles for filter tabs to ensure a consistent and user-friendly interface. --- .../common/file-explorer-modal/index.tsx | 20 +++++ .../common/file-explorer-modal/styles.scss | 8 ++ .../file-explorer-modal/use-file-explorer.ts | 79 +++++++++++++++---- .../common/file-explorer-modal/utils.ts | 65 ++++++++++----- .../src/locales/en/translation.json | 1 + .../src/locales/es/translation.json | 1 + .../src/locales/fr/translation.json | 1 + .../src/locales/pt-BR/translation.json | 1 + .../src/locales/ru/translation.json | 1 + 9 files changed, 142 insertions(+), 35 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index 981587c14..0c8b47f1d 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -7,6 +7,7 @@ import { } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; import { Modal } from "../modal"; +import { Tabs, type TabsItem } from "../tabs"; import { VerticalFocusGroup } from "../vertical-focus-group"; import { EmptyState } from "../empty-state"; import { Skeleton } from "../skeleton"; @@ -32,6 +33,7 @@ function getEntryFocusId(path: string) { function getInitialFocusId(vm: ReturnType) { const firstDrive = vm.drives[0]; const firstEntry = vm.filteredEntries[0]; + const firstFilterTab = vm.filterTabItems[0]; if (vm.showSelectThisDir) { return "file-explorer-select-dir"; @@ -45,6 +47,10 @@ function getInitialFocusId(vm: ReturnType) { return getEntryFocusId(firstEntry.path); } + if (vm.shouldShowFilterTabs && firstFilterTab) { + return firstFilterTab.id; + } + return undefined; } @@ -52,6 +58,7 @@ export function FileExplorerModal(props: Readonly) { const { t } = useTranslation("big_picture"); const vm = useFileExplorer(props); const initialFocusId = getInitialFocusId(vm); + const filterTabItems = vm.filterTabItems as Array>; return ( ) { regionId={vm.fileListRegionId} className="file-explorer__list" > + {vm.shouldShowFilterTabs && ( +
+ +
+ )} + {vm.showSelectThisDir && ( ([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [activeFilterId, setActiveFilterId] = useState(null); const generatedId = useId(); const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; const pathInputPlaceholder = t("file_explorer_path_placeholder"); const drivesLabel = t("file_explorer_drives"); + const filterLabel = t("file_explorer_filters"); const emptyFolderTitle = t("file_explorer_empty_folder"); const emptyDirectoryChoiceTitle = t("file_explorer_empty_directory"); const fileExplorerErrorFallback = t("file_explorer_error_default"); @@ -87,10 +89,27 @@ export function useFileExplorer({ }), [t] ); + const filterGroups = useMemo(() => normalizeFilters(filters), [filters]); + + const activeFilter = useMemo(() => { + if (filterGroups.length === 0) return null; + + return ( + filterGroups.find((group) => group.id === activeFilterId) ?? + filterGroups.find((group) => group.mode === "extensions") ?? + filterGroups[0] ?? + null + ); + }, [activeFilterId, filterGroups]); useEffect(() => { if (!visible) { + setCurrentPath(""); + setEntries([]); + setDrives([]); + setIsLoading(false); setError(null); + setActiveFilterId(null); return; } @@ -106,11 +125,7 @@ export function useFileExplorer({ }, [visible, initialPath]); useEffect(() => { - if (!visible) { - setEntries([]); - setDrives([]); - return; - } + if (!visible) return; if (!currentPath) { setEntries([]); @@ -159,12 +174,7 @@ export function useFileExplorer({ ]); useEffect(() => { - if (!visible) { - setDrives([]); - return; - } - - if (currentPath || drives.length > 0) return; + if (!visible) return; let cancelled = false; @@ -182,16 +192,34 @@ export function useFileExplorer({ return () => { cancelled = true; }; - }, [visible, currentPath, drives.length]); + }, [visible]); + + useEffect(() => { + if (!visible) return; + if (filterGroups.length === 0) { + setActiveFilterId(null); + return; + } + + const hasActiveFilter = filterGroups.some( + (group) => group.id === activeFilterId + ); - const allowedExtensions = useMemo(() => normalizeFilters(filters), [filters]); + if (hasActiveFilter) return; + + const defaultFilter = + filterGroups.find((group) => group.mode === "extensions") ?? + filterGroups[0]; + + setActiveFilterId(defaultFilter?.id ?? null); + }, [visible, filterGroups, activeFilterId]); const filteredEntries = useMemo(() => { if (drives.length > 0 && !currentPath) return []; return entries.filter((entry) => - matchesFilters(entry, allowedExtensions, selectDirectory) + matchesFilters(entry, activeFilter, selectDirectory) ); - }, [entries, allowedExtensions, drives, selectDirectory, currentPath]); + }, [entries, activeFilter, drives, selectDirectory, currentPath]); const handleBPress = useCallback(() => { const parent = getParentPath(currentPath); @@ -251,9 +279,28 @@ export function useFileExplorer({ setCurrentPath(drive); }, []); + const selectFilter = useCallback((filterId: string) => { + setActiveFilterId(filterId); + }, []); + + const shouldShowFilterTabs = filterGroups.length > 1; + + const filterTabItems = useMemo( + () => + filterGroups.map((group) => ({ + id: group.id, + value: group.id, + label: group.label, + })), + [filterGroups] + ); + return { + activeFilterId, currentPath, fileListRegionId, + filterLabel, + filterTabItems, isLoading, error, drives, @@ -264,10 +311,12 @@ export function useFileExplorer({ emptyTitle: selectDirectory ? emptyDirectoryChoiceTitle : emptyFolderTitle, showSelectThisDir, showDriveList, + shouldShowFilterTabs, hasParent, handleEntrySelect, handleSelectThisDirectory, goToParent, navigateToDrive, + selectFilter, }; } diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts index 2e8acd413..7c9345ce4 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/utils.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -6,9 +6,11 @@ export interface FileFilter { extensions: string[]; } -interface NormalizedFilters { - specificExtensions: Set; - hasWildcard: boolean; +export interface FilterGroup { + id: string; + label: string; + mode: "all" | "extensions"; + extensions: Set; } const WINDOWS_DRIVE_RE = /^[A-Za-z]:$/; @@ -54,33 +56,56 @@ export function getEntryMeta(entry: DirectoryEntry): string { export function normalizeFilters( filters?: FileFilter[] -): NormalizedFilters | null { - if (!filters || filters.length === 0) return null; +): FilterGroup[] { + if (!filters || filters.length === 0) return []; + + const normalizedFilters: FilterGroup[] = []; + + filters.forEach((filter, index) => { + const normalizedExtensions = Array.from( + new Set( + filter.extensions + .map((extension) => extension.trim().toLowerCase()) + .filter(Boolean) + ) + ); - const allExtensions = filters.flatMap((f) => f.extensions); - const hasWildcard = allExtensions.includes("*"); - const specificExtensions = allExtensions.filter((ext) => ext !== "*"); + const specificExtensions = normalizedExtensions.filter( + (extension) => extension !== "*" + ); - if (specificExtensions.length === 0 && !hasWildcard) return null; + if (specificExtensions.length > 0) { + normalizedFilters.push({ + id: `file-explorer-filter-${index}`, + label: filter.name, + mode: "extensions", + extensions: new Set(specificExtensions), + }); + return; + } + + if (normalizedExtensions.includes("*")) { + normalizedFilters.push({ + id: `file-explorer-filter-${index}`, + label: filter.name, + mode: "all", + extensions: new Set(), + }); + } + }); - return { - specificExtensions: new Set( - specificExtensions.map((ext) => ext.toLowerCase()) - ), - hasWildcard, - }; + return normalizedFilters; } export function matchesFilters( entry: DirectoryEntry, - filters: NormalizedFilters | null, + activeFilter: FilterGroup | null, directoryOnly: boolean ): boolean { if (directoryOnly && !entry.isDirectory) return false; if (entry.isDirectory) return true; - if (!filters) return true; - if (filters.specificExtensions.size === 0) return filters.hasWildcard; - if (filters.specificExtensions.has(entry.extension)) return true; + if (!activeFilter) return true; + if (activeFilter.mode === "all") return true; - return filters.hasWildcard && entry.extension === ""; + return activeFilter.extensions.has(entry.extension); } diff --git a/src/big-picture/src/locales/en/translation.json b/src/big-picture/src/locales/en/translation.json index 2e399d2ff..7e1135ffb 100644 --- a/src/big-picture/src/locales/en/translation.json +++ b/src/big-picture/src/locales/en/translation.json @@ -758,6 +758,7 @@ "file_explorer_error_enoent": "This location doesn't exist.", "file_explorer_error_enotdir": "This is not a directory.", "file_explorer_error_default": "This location can't be opened.", + "file_explorer_filters": "File filters", "file_explorer_select_this_directory": "Select this directory", "file_explorer_select_download_directory": "Select Download Directory" } diff --git a/src/big-picture/src/locales/es/translation.json b/src/big-picture/src/locales/es/translation.json index 221b9a487..d77b53ac9 100644 --- a/src/big-picture/src/locales/es/translation.json +++ b/src/big-picture/src/locales/es/translation.json @@ -747,6 +747,7 @@ "file_explorer_error_enoent": "Esta ubicación no existe.", "file_explorer_error_enotdir": "Esta ruta no es una carpeta.", "file_explorer_error_default": "No se puede abrir esta ubicación.", + "file_explorer_filters": "Filtros de archivo", "file_explorer_select_this_directory": "Seleccionar esta carpeta", "file_explorer_select_download_directory": "Seleccionar directorio de descarga" } diff --git a/src/big-picture/src/locales/fr/translation.json b/src/big-picture/src/locales/fr/translation.json index 2b4668d62..6ba63800b 100644 --- a/src/big-picture/src/locales/fr/translation.json +++ b/src/big-picture/src/locales/fr/translation.json @@ -739,6 +739,7 @@ "file_explorer_error_enoent": "Cet emplacement n'existe pas.", "file_explorer_error_enotdir": "Ce chemin n'est pas un dossier.", "file_explorer_error_default": "Impossible d'ouvrir cet emplacement.", + "file_explorer_filters": "Filtres de fichiers", "file_explorer_select_this_directory": "Sélectionner ce dossier", "file_explorer_select_download_directory": "Sélectionner le dossier de téléchargement" } diff --git a/src/big-picture/src/locales/pt-BR/translation.json b/src/big-picture/src/locales/pt-BR/translation.json index 0a4e322d9..301712609 100644 --- a/src/big-picture/src/locales/pt-BR/translation.json +++ b/src/big-picture/src/locales/pt-BR/translation.json @@ -752,6 +752,7 @@ "file_explorer_error_enoent": "Este local não existe.", "file_explorer_error_enotdir": "Este caminho não é um diretório.", "file_explorer_error_default": "Não foi possível abrir este local.", + "file_explorer_filters": "Filtros de arquivo", "file_explorer_select_this_directory": "Selecionar esta pasta", "file_explorer_select_download_directory": "Selecionar diretório de download" } diff --git a/src/big-picture/src/locales/ru/translation.json b/src/big-picture/src/locales/ru/translation.json index dd4c2eccf..bcb1c3913 100644 --- a/src/big-picture/src/locales/ru/translation.json +++ b/src/big-picture/src/locales/ru/translation.json @@ -766,6 +766,7 @@ "file_explorer_error_enoent": "Это расположение не существует.", "file_explorer_error_enotdir": "Этот путь не является папкой.", "file_explorer_error_default": "Не удалось открыть это расположение.", + "file_explorer_filters": "Фильтры файлов", "file_explorer_select_this_directory": "Выбрать эту папку", "file_explorer_select_download_directory": "Выбрать папку загрузки" } From 753001cc9edfc223cf582f5c9c22155a00030d36 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 13:13:45 -0300 Subject: [PATCH 12/14] refactor: streamline normalizeFilters function in file explorer modal - Simplified the `normalizeFilters` function by removing unnecessary line breaks for improved readability. - Ensured consistent formatting in the utility functions of the file explorer modal. --- .../src/components/common/file-explorer-modal/utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts index 7c9345ce4..bf22b1603 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/utils.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -54,9 +54,7 @@ export function getEntryMeta(entry: DirectoryEntry): string { return formatBytes(entry.size); } -export function normalizeFilters( - filters?: FileFilter[] -): FilterGroup[] { +export function normalizeFilters(filters?: FileFilter[]): FilterGroup[] { if (!filters || filters.length === 0) return []; const normalizedFilters: FilterGroup[] = []; From 37f53221e0d660cd3d2eaee4ae7a41aa803c18fa Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 13:46:15 -0300 Subject: [PATCH 13/14] feat: enhance file explorer state management with loading indicators - Added `isResolvingStartPath` state to track the resolution of the initial path in `useFileExplorer`. - Updated loading logic to improve user experience by managing loading states more effectively. - Ensured proper handling of loading states when the current path is not set or when resolving the start path. --- .../file-explorer-modal/use-file-explorer.ts | 21 ++++++++++++++++++- .../game-settings-modal/compatibility-tab.tsx | 8 +++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts index c1b337bba..8c25e5814 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -71,6 +71,7 @@ export function useFileExplorer({ const [entries, setEntries] = useState([]); const [drives, setDrives] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isResolvingStartPath, setIsResolvingStartPath] = useState(false); const [error, setError] = useState(null); const [activeFilterId, setActiveFilterId] = useState(null); const generatedId = useId(); @@ -108,15 +109,26 @@ export function useFileExplorer({ setEntries([]); setDrives([]); setIsLoading(false); + setIsResolvingStartPath(false); setError(null); setActiveFilterId(null); return; } + setIsLoading(true); + setIsResolvingStartPath(true); + let cancelled = false; resolveStartPath(initialPath).then((path) => { - if (!cancelled) setCurrentPath(path); + if (cancelled) return; + + setCurrentPath(path); + setIsResolvingStartPath(false); + + if (!path) { + setIsLoading(false); + } }); return () => { @@ -129,6 +141,12 @@ export function useFileExplorer({ if (!currentPath) { setEntries([]); + setError(null); + + if (!isResolvingStartPath) { + setIsLoading(false); + } + return; } @@ -169,6 +187,7 @@ export function useFileExplorer({ }, [ visible, currentPath, + isResolvingStartPath, fileExplorerErrorFallback, fileExplorerErrorMessages, ]); diff --git a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx index eec8d6e43..4fb3ffc13 100644 --- a/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx +++ b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx @@ -252,7 +252,9 @@ export function GameCompatibilitySettingsTab({ focusId={GAME_COMPATIBILITY_SETTINGS_WINE_SELECT_ID} variant="secondary" icon={} - onClick={handleSelectWinePrefix} + onClick={() => { + handleSelectWinePrefix().catch(() => {}); + }} focusNavigationOverrides={{ left: { type: "item", @@ -348,7 +350,9 @@ export function GameCompatibilitySettingsTab({ setWinePickerOpen(false)} - onSelect={handleWinePrefixPicked} + onSelect={(path) => { + handleWinePrefixPicked(path).catch(() => {}); + }} title={t("wine_prefix")} initialPath={winePickerInitialPath} selectDirectory From 2c2897e225cab8d1c9340d97139ba087998784d8 Mon Sep 17 00:00:00 2001 From: Hachi-R Date: Thu, 2 Jul 2026 13:51:49 -0300 Subject: [PATCH 14/14] refactor: remove filter tabs from FileExplorerModal and streamline filter logic - Eliminated filter tabs from FileExplorerModal to simplify the user interface. - Refactored `useFileExplorer` to remove active filter state management and related logic. - Updated `normalizeFilters` to return a more efficient structure for filter handling. - Removed unused translation keys related to file filters from localization files. --- .../common/file-explorer-modal/index.tsx | 20 ----- .../common/file-explorer-modal/styles.scss | 8 -- .../file-explorer-modal/use-file-explorer.ts | 61 +--------------- .../common/file-explorer-modal/utils.ts | 73 +++++++++---------- .../src/locales/en/translation.json | 1 - .../src/locales/es/translation.json | 1 - .../src/locales/fr/translation.json | 1 - .../src/locales/pt-BR/translation.json | 1 - .../src/locales/ru/translation.json | 1 - 9 files changed, 36 insertions(+), 131 deletions(-) diff --git a/src/big-picture/src/components/common/file-explorer-modal/index.tsx b/src/big-picture/src/components/common/file-explorer-modal/index.tsx index 0c8b47f1d..981587c14 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/index.tsx +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -7,7 +7,6 @@ import { } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; import { Modal } from "../modal"; -import { Tabs, type TabsItem } from "../tabs"; import { VerticalFocusGroup } from "../vertical-focus-group"; import { EmptyState } from "../empty-state"; import { Skeleton } from "../skeleton"; @@ -33,7 +32,6 @@ function getEntryFocusId(path: string) { function getInitialFocusId(vm: ReturnType) { const firstDrive = vm.drives[0]; const firstEntry = vm.filteredEntries[0]; - const firstFilterTab = vm.filterTabItems[0]; if (vm.showSelectThisDir) { return "file-explorer-select-dir"; @@ -47,10 +45,6 @@ function getInitialFocusId(vm: ReturnType) { return getEntryFocusId(firstEntry.path); } - if (vm.shouldShowFilterTabs && firstFilterTab) { - return firstFilterTab.id; - } - return undefined; } @@ -58,7 +52,6 @@ export function FileExplorerModal(props: Readonly) { const { t } = useTranslation("big_picture"); const vm = useFileExplorer(props); const initialFocusId = getInitialFocusId(vm); - const filterTabItems = vm.filterTabItems as Array>; return ( ) { regionId={vm.fileListRegionId} className="file-explorer__list" > - {vm.shouldShowFilterTabs && ( -
- -
- )} - {vm.showSelectThisDir && ( (null); - const [activeFilterId, setActiveFilterId] = useState(null); const generatedId = useId(); const fileListRegionId = `file-explorer-region-${generatedId.replaceAll(":", "")}`; const pathInputPlaceholder = t("file_explorer_path_placeholder"); const drivesLabel = t("file_explorer_drives"); - const filterLabel = t("file_explorer_filters"); const emptyFolderTitle = t("file_explorer_empty_folder"); const emptyDirectoryChoiceTitle = t("file_explorer_empty_directory"); const fileExplorerErrorFallback = t("file_explorer_error_default"); @@ -90,18 +88,7 @@ export function useFileExplorer({ }), [t] ); - const filterGroups = useMemo(() => normalizeFilters(filters), [filters]); - - const activeFilter = useMemo(() => { - if (filterGroups.length === 0) return null; - - return ( - filterGroups.find((group) => group.id === activeFilterId) ?? - filterGroups.find((group) => group.mode === "extensions") ?? - filterGroups[0] ?? - null - ); - }, [activeFilterId, filterGroups]); + const effectiveFilters = useMemo(() => normalizeFilters(filters), [filters]); useEffect(() => { if (!visible) { @@ -111,7 +98,6 @@ export function useFileExplorer({ setIsLoading(false); setIsResolvingStartPath(false); setError(null); - setActiveFilterId(null); return; } @@ -213,32 +199,12 @@ export function useFileExplorer({ }; }, [visible]); - useEffect(() => { - if (!visible) return; - if (filterGroups.length === 0) { - setActiveFilterId(null); - return; - } - - const hasActiveFilter = filterGroups.some( - (group) => group.id === activeFilterId - ); - - if (hasActiveFilter) return; - - const defaultFilter = - filterGroups.find((group) => group.mode === "extensions") ?? - filterGroups[0]; - - setActiveFilterId(defaultFilter?.id ?? null); - }, [visible, filterGroups, activeFilterId]); - const filteredEntries = useMemo(() => { if (drives.length > 0 && !currentPath) return []; return entries.filter((entry) => - matchesFilters(entry, activeFilter, selectDirectory) + matchesFilters(entry, effectiveFilters, selectDirectory) ); - }, [entries, activeFilter, drives, selectDirectory, currentPath]); + }, [entries, effectiveFilters, drives, selectDirectory, currentPath]); const handleBPress = useCallback(() => { const parent = getParentPath(currentPath); @@ -298,28 +264,9 @@ export function useFileExplorer({ setCurrentPath(drive); }, []); - const selectFilter = useCallback((filterId: string) => { - setActiveFilterId(filterId); - }, []); - - const shouldShowFilterTabs = filterGroups.length > 1; - - const filterTabItems = useMemo( - () => - filterGroups.map((group) => ({ - id: group.id, - value: group.id, - label: group.label, - })), - [filterGroups] - ); - return { - activeFilterId, currentPath, fileListRegionId, - filterLabel, - filterTabItems, isLoading, error, drives, @@ -330,12 +277,10 @@ export function useFileExplorer({ emptyTitle: selectDirectory ? emptyDirectoryChoiceTitle : emptyFolderTitle, showSelectThisDir, showDriveList, - shouldShowFilterTabs, hasParent, handleEntrySelect, handleSelectThisDirectory, goToParent, navigateToDrive, - selectFilter, }; } diff --git a/src/big-picture/src/components/common/file-explorer-modal/utils.ts b/src/big-picture/src/components/common/file-explorer-modal/utils.ts index bf22b1603..b1b9eaeb4 100644 --- a/src/big-picture/src/components/common/file-explorer-modal/utils.ts +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -6,10 +6,8 @@ export interface FileFilter { extensions: string[]; } -export interface FilterGroup { - id: string; - label: string; - mode: "all" | "extensions"; +interface EffectiveFilters { + allowAll: boolean; extensions: Set; } @@ -54,56 +52,51 @@ export function getEntryMeta(entry: DirectoryEntry): string { return formatBytes(entry.size); } -export function normalizeFilters(filters?: FileFilter[]): FilterGroup[] { - if (!filters || filters.length === 0) return []; +export function normalizeFilters( + filters?: FileFilter[] +): EffectiveFilters | null { + if (!filters || filters.length === 0) return null; - const normalizedFilters: FilterGroup[] = []; - - filters.forEach((filter, index) => { - const normalizedExtensions = Array.from( - new Set( + const normalizedExtensions = Array.from( + new Set( + filters.flatMap((filter) => filter.extensions .map((extension) => extension.trim().toLowerCase()) .filter(Boolean) ) - ); - - const specificExtensions = normalizedExtensions.filter( - (extension) => extension !== "*" - ); - - if (specificExtensions.length > 0) { - normalizedFilters.push({ - id: `file-explorer-filter-${index}`, - label: filter.name, - mode: "extensions", - extensions: new Set(specificExtensions), - }); - return; - } + ) + ); + + const specificExtensions = normalizedExtensions.filter( + (extension) => extension !== "*" + ); + + if (specificExtensions.length > 0) { + return { + allowAll: false, + extensions: new Set(specificExtensions), + }; + } - if (normalizedExtensions.includes("*")) { - normalizedFilters.push({ - id: `file-explorer-filter-${index}`, - label: filter.name, - mode: "all", - extensions: new Set(), - }); - } - }); + if (normalizedExtensions.includes("*")) { + return { + allowAll: true, + extensions: new Set(), + }; + } - return normalizedFilters; + return null; } export function matchesFilters( entry: DirectoryEntry, - activeFilter: FilterGroup | null, + filters: EffectiveFilters | null, directoryOnly: boolean ): boolean { if (directoryOnly && !entry.isDirectory) return false; if (entry.isDirectory) return true; - if (!activeFilter) return true; - if (activeFilter.mode === "all") return true; + if (!filters) return true; + if (filters.allowAll) return true; - return activeFilter.extensions.has(entry.extension); + return filters.extensions.has(entry.extension); } diff --git a/src/big-picture/src/locales/en/translation.json b/src/big-picture/src/locales/en/translation.json index 7e1135ffb..2e399d2ff 100644 --- a/src/big-picture/src/locales/en/translation.json +++ b/src/big-picture/src/locales/en/translation.json @@ -758,7 +758,6 @@ "file_explorer_error_enoent": "This location doesn't exist.", "file_explorer_error_enotdir": "This is not a directory.", "file_explorer_error_default": "This location can't be opened.", - "file_explorer_filters": "File filters", "file_explorer_select_this_directory": "Select this directory", "file_explorer_select_download_directory": "Select Download Directory" } diff --git a/src/big-picture/src/locales/es/translation.json b/src/big-picture/src/locales/es/translation.json index d77b53ac9..221b9a487 100644 --- a/src/big-picture/src/locales/es/translation.json +++ b/src/big-picture/src/locales/es/translation.json @@ -747,7 +747,6 @@ "file_explorer_error_enoent": "Esta ubicación no existe.", "file_explorer_error_enotdir": "Esta ruta no es una carpeta.", "file_explorer_error_default": "No se puede abrir esta ubicación.", - "file_explorer_filters": "Filtros de archivo", "file_explorer_select_this_directory": "Seleccionar esta carpeta", "file_explorer_select_download_directory": "Seleccionar directorio de descarga" } diff --git a/src/big-picture/src/locales/fr/translation.json b/src/big-picture/src/locales/fr/translation.json index 6ba63800b..2b4668d62 100644 --- a/src/big-picture/src/locales/fr/translation.json +++ b/src/big-picture/src/locales/fr/translation.json @@ -739,7 +739,6 @@ "file_explorer_error_enoent": "Cet emplacement n'existe pas.", "file_explorer_error_enotdir": "Ce chemin n'est pas un dossier.", "file_explorer_error_default": "Impossible d'ouvrir cet emplacement.", - "file_explorer_filters": "Filtres de fichiers", "file_explorer_select_this_directory": "Sélectionner ce dossier", "file_explorer_select_download_directory": "Sélectionner le dossier de téléchargement" } diff --git a/src/big-picture/src/locales/pt-BR/translation.json b/src/big-picture/src/locales/pt-BR/translation.json index 301712609..0a4e322d9 100644 --- a/src/big-picture/src/locales/pt-BR/translation.json +++ b/src/big-picture/src/locales/pt-BR/translation.json @@ -752,7 +752,6 @@ "file_explorer_error_enoent": "Este local não existe.", "file_explorer_error_enotdir": "Este caminho não é um diretório.", "file_explorer_error_default": "Não foi possível abrir este local.", - "file_explorer_filters": "Filtros de arquivo", "file_explorer_select_this_directory": "Selecionar esta pasta", "file_explorer_select_download_directory": "Selecionar diretório de download" } diff --git a/src/big-picture/src/locales/ru/translation.json b/src/big-picture/src/locales/ru/translation.json index bcb1c3913..dd4c2eccf 100644 --- a/src/big-picture/src/locales/ru/translation.json +++ b/src/big-picture/src/locales/ru/translation.json @@ -766,7 +766,6 @@ "file_explorer_error_enoent": "Это расположение не существует.", "file_explorer_error_enotdir": "Этот путь не является папкой.", "file_explorer_error_default": "Не удалось открыть это расположение.", - "file_explorer_filters": "Фильтры файлов", "file_explorer_select_this_directory": "Выбрать эту папку", "file_explorer_select_download_directory": "Выбрать папку загрузки" }