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 0000000000..981587c143 --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/index.tsx @@ -0,0 +1,191 @@ +import "./styles.scss"; + +import { + CheckCircleIcon, + 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"; +import { Skeleton } from "../skeleton"; +import { getEntryIcon } from "../../../helpers"; +import { getEntryMeta } from "./utils"; +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 { t } = useTranslation("big_picture"); + const vm = useFileExplorer(props); + const initialFocusId = getInitialFocusId(vm); + + return ( + +
+ {vm.isLoading && ( +
+
+ {Array.from({ length: vm.SKELETON_COUNT }, (_, i) => ( + + ))} +
+
+ )} + + {vm.error && ( +
+
+ {vm.error} +
+
+ )} + + {!vm.isLoading && !vm.error && ( +
+ + + +
+ )} + + {!vm.isLoading && !vm.error && ( + + {vm.showSelectThisDir && ( + + + + )} + + {vm.showDriveList && ( + <> +
+ {vm.DRIVES_LABEL} +
+ + {vm.drives.map((drive) => ( + vm.navigateToDrive(drive) }} + asChild + > + + + ))} + + )} + + {vm.filteredEntries.length === 0 && + !vm.showDriveList && + !vm.isLoading && ( + } + title={vm.emptyTitle} + /> + )} + + {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 new file mode 100644 index 0000000000..b8c840fbf9 --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/styles.scss @@ -0,0 +1,170 @@ +.file-explorer-modal { + max-height: min(100%, calc(100vh - 4rem), 60vh); + + .modal__content { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; + } +} + +.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%; + pointer-events: none; + } + + &__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; + pointer-events: none; + + &: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/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 0000000000..d367c2a92e --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/use-file-explorer.ts @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigationScreenActions } from "../../../hooks"; +import type { DirectoryEntry } from "../../../helpers"; +import { + getParentPath, + matchesFilters, + normalizeFilters, + type FileFilter, +} from "./utils"; + +const SKELETON_COUNT = 6; +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 errorMessages[code] ?? fallbackMessage; + } + + return fallbackMessage; +} + +export interface FileExplorerModalProps { + visible: boolean; + onClose: () => void; + onSelect: (path: string) => void; + title: string; + initialPath?: string; + filters?: FileFilter[]; + 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, + onSelect, + initialPath, + filters, + selectDirectory = false, +}: Readonly) { + const { t } = useTranslation("big_picture"); + const [currentPath, setCurrentPath] = useState(""); + const [entries, setEntries] = useState([]); + const [drives, setDrives] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isResolvingStartPath, setIsResolvingStartPath] = useState(false); + 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] + ); + const effectiveFilters = useMemo(() => normalizeFilters(filters), [filters]); + + useEffect(() => { + if (!visible) { + setCurrentPath(""); + setEntries([]); + setDrives([]); + setIsLoading(false); + setIsResolvingStartPath(false); + setError(null); + return; + } + + setIsLoading(true); + setIsResolvingStartPath(true); + + let cancelled = false; + + resolveStartPath(initialPath).then((path) => { + if (cancelled) return; + + setCurrentPath(path); + setIsResolvingStartPath(false); + + if (!path) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [visible, initialPath]); + + useEffect(() => { + if (!visible) return; + + if (!currentPath) { + setEntries([]); + setError(null); + + if (!isResolvingStartPath) { + setIsLoading(false); + } + + 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( + getErrorMessage( + err, + fileExplorerErrorMessages, + fileExplorerErrorFallback + ) + ); + setEntries([]); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + void load(); + + return () => { + cancelled = true; + }; + }, [ + visible, + currentPath, + isResolvingStartPath, + fileExplorerErrorFallback, + fileExplorerErrorMessages, + ]); + + useEffect(() => { + if (!visible) return; + + let cancelled = false; + + const loadDrives = async () => { + try { + const result = await globalThis.window.electron.listDrives(); + if (!cancelled) setDrives(result); + } catch { + // Drives failed to load — the explorer can still use the current path. + } + }; + + void loadDrives(); + + return () => { + cancelled = true; + }; + }, [visible]); + + const filteredEntries = useMemo(() => { + if (drives.length > 0 && !currentPath) return []; + return entries.filter((entry) => + matchesFilters(entry, effectiveFilters, selectDirectory) + ); + }, [entries, effectiveFilters, drives, selectDirectory, currentPath]); + + const handleBPress = useCallback(() => { + const parent = getParentPath(currentPath); + if (parent) return setCurrentPath(parent); + + if (!currentPath) return onClose(); + + if (drives.length > 0) { + setCurrentPath(""); + return; + } + + onClose(); + }, [currentPath, drives.length, 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 hasParent = Boolean( + (currentPath && getParentPath(currentPath)) || + (currentPath && drives.length > 0) + ); + + const goToParent = useCallback(() => { + const parent = getParentPath(currentPath); + if (parent) return setCurrentPath(parent); + + if (drives.length > 0) setCurrentPath(""); + }, [currentPath, drives.length]); + + const showSelectThisDir = Boolean(selectDirectory && currentPath); + const showDriveList = !currentPath && drives.length > 0; + + const navigateToDrive = useCallback((drive: string) => { + setCurrentPath(drive); + }, []); + + return { + currentPath, + fileListRegionId, + isLoading, + error, + drives, + filteredEntries, + SKELETON_COUNT, + PATH_INPUT_PLACEHOLDER: pathInputPlaceholder, + DRIVES_LABEL: drivesLabel, + emptyTitle: selectDirectory ? emptyDirectoryChoiceTitle : emptyFolderTitle, + showSelectThisDir, + showDriveList, + hasParent, + handleEntrySelect, + handleSelectThisDirectory, + 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 0000000000..b1b9eaeb4b --- /dev/null +++ b/src/big-picture/src/components/common/file-explorer-modal/utils.ts @@ -0,0 +1,102 @@ +import { formatBytes } from "@shared"; +import type { DirectoryEntry } from "../../../helpers"; + +export interface FileFilter { + name: string; + extensions: string[]; +} + +interface EffectiveFilters { + allowAll: boolean; + extensions: Set; +} + +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 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; + + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) return null; + + return normalized.substring(0, lastSlash) || "/"; +} + +export function getEntryMeta(entry: DirectoryEntry): string { + if (!entry.isFile) return ""; + return formatBytes(entry.size); +} + +export function normalizeFilters( + filters?: FileFilter[] +): EffectiveFilters | null { + if (!filters || filters.length === 0) return null; + + 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) { + return { + allowAll: false, + extensions: new Set(specificExtensions), + }; + } + + if (normalizedExtensions.includes("*")) { + return { + allowAll: true, + extensions: new Set(), + }; + } + + return null; +} + +export function matchesFilters( + entry: DirectoryEntry, + filters: EffectiveFilters | null, + directoryOnly: boolean +): boolean { + if (directoryOnly && !entry.isDirectory) return false; + if (entry.isDirectory) return true; + if (!filters) return true; + if (filters.allowAll) return true; + + return filters.extensions.has(entry.extension); +} diff --git a/src/big-picture/src/components/common/index.ts b/src/big-picture/src/components/common/index.ts index 925c7311c5..0f49efa95e 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 ede693755b..38c25787fd 100644 --- a/src/big-picture/src/components/common/modal/styles.scss +++ b/src/big-picture/src/components/common/modal/styles.scss @@ -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/components/pages/game/game-settings-modal/compatibility-tab.tsx b/src/big-picture/src/components/pages/game/game-settings-modal/compatibility-tab.tsx index c7f9f71ae1..4fb3ffc13a 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,10 @@ export function GameCompatibilitySettingsTab({ const [autoRunMangohud, setAutoRunMangohud] = useState( game.autoRunMangohud ?? false ); + const [winePickerOpen, setWinePickerOpen] = useState(false); + const [winePickerInitialPath, setWinePickerInitialPath] = useState< + string | undefined + >(); useEffect(() => { setSelectedProtonPath(game.protonPath ?? ""); @@ -152,21 +156,18 @@ export function GameCompatibilitySettingsTab({ const handleSelectWinePrefix = useCallback(async () => { const defaultPath = await electron.getDefaultWinePrefixSelectionPath(); - - 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]); + setWinePickerInitialPath(winePrefixPath ?? defaultPath ?? undefined); + setWinePickerOpen(true); + }, [electron, 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,7 +251,7 @@ 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 14936d139d..fc89cbb658 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 @@ -7,17 +7,19 @@ import { 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 +81,10 @@ export interface GameLaunchSettingsProps { creatingSteamShortcut: boolean; steamShortcutExists: boolean; shouldShowCreateStartMenuShortcut: boolean; - onChangeExecutableLocation: () => Promise; + execPickerInitialPath: string; + execPickerFilters: FileFilter[]; + discPickerFilters: FileFilter[]; + onProcessExecPath: (path: string) => Promise; onClearExecutablePath: () => Promise; onOpenSaveFolder: () => Promise; onChangeLaunchOptions: (value: string) => void; @@ -90,7 +95,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 +105,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 +166,7 @@ function LaunchboxDiscsSection({ focusId={GAME_LAUNCH_SETTINGS_PRIMARY_CONTROL_ID} variant="primary" icon={} - onClick={() => { - void onAddDiscFile(); - }} + onClick={() => onAddDiscFile()} > {t("add_disc")} @@ -176,9 +179,7 @@ function LaunchboxDiscsSection({ focusId={GAME_LAUNCH_SETTINGS_ADD_DISC_FILE_ID} variant="secondary" icon={} - onClick={() => { - void onAddDiscFile(); - }} + onClick={() => onAddDiscFile()} > {t("add_disc")} @@ -229,7 +230,7 @@ interface ExecutableSectionProps { saveFolderTooltipContent: string; loadingSaveFolder: boolean; saveFolderPath: string | null; - onChangeExecutableLocation: () => Promise; + onOpenExecPicker: () => void; onClearExecutablePath: () => Promise; onOpenSaveFolder: () => Promise; } @@ -240,7 +241,7 @@ function ExecutableSection({ saveFolderTooltipContent, loadingSaveFolder, saveFolderPath, - onChangeExecutableLocation, + onOpenExecPicker, onClearExecutablePath, onOpenSaveFolder, }: Readonly) { @@ -260,7 +261,7 @@ function ExecutableSection({
void onChangeExecutableLocation() }} + actions={{ primary: () => onOpenExecPicker() }} asChild > @@ -296,7 +297,7 @@ function ExecutableSection({ focusId={GAME_LAUNCH_SETTINGS_EXEC_PATH_SELECT_ID} variant="secondary" icon={} - onClick={() => void onChangeExecutableLocation()} + onClick={() => onOpenExecPicker()} focusNavigationOverrides={{ left: { type: "item", @@ -528,7 +529,10 @@ export function GameLaunchSettingsTab({ creatingSteamShortcut, steamShortcutExists, shouldShowCreateStartMenuShortcut, - onChangeExecutableLocation, + execPickerInitialPath, + execPickerFilters, + discPickerFilters, + onProcessExecPath, onClearExecutablePath, onOpenSaveFolder, onChangeLaunchOptions, @@ -539,11 +543,13 @@ 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 isCustomGame = game.shop === "custom"; const discs = game.discs ?? []; const selectedDisc = @@ -558,48 +564,99 @@ 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(() => { + setDiscPickerOpen(true); + }, []); + 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 b1887f5fef..98b8f0fc07 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 @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { platformToSystem } from "@renderer/helpers"; import { getGameExecutableFilters } from "@shared"; +import type { FileFilter } from "../../../common"; import { useBigPictureToast } from "../../../../hooks"; import { applyClassicsDiscUpdate, @@ -31,6 +32,7 @@ interface UseGameSettingsModalStateResult { } type CustomAssetType = "icon" | "logo" | "hero"; +const IMAGE_FILE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const; export function useGameSettingsModalState({ game, @@ -38,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(""); @@ -50,6 +52,8 @@ export function useGameSettingsModalState({ const [automaticCloudSync, setAutomaticCloudSync] = useState( () => game?.automaticCloudSync ?? false ); + const [execPickerInitialPath, setExecPickerInitialPath] = useState(""); + const [discPickerFilters, setDiscPickerFilters] = useState([]); const launchOptionsDebounceRef = useRef(null); const persistedLaunchOptionsRef = useRef(""); const selectedDisc = useMemo(() => { @@ -66,6 +70,60 @@ export function useGameSettingsModalState({ setAutomaticCloudSync(game?.automaticCloudSync ?? false); }, [game?.automaticCloudSync]); + const getDownloadsPath = useCallback(async () => { + const userPreferences = await globalThis.window.electron + .getUserPreferences() + .catch(() => null); + + return ( + userPreferences?.downloadsPath ?? + (await globalThis.window.electron.getDefaultDownloadsPath()) + ); + }, []); + + useEffect(() => { + if (!visible || game?.shop === "launchbox") return; + + let cancelled = false; + + getDownloadsPath().then((path) => { + if (!cancelled) setExecPickerInitialPath(path); + }); + + return () => { + cancelled = true; + }; + }, [game?.shop, getDownloadsPath, visible]); + + useEffect(() => { + if (!visible || game?.shop !== "launchbox") { + setDiscPickerFilters([]); + return; + } + + let cancelled = false; + + const loadDiscFilters = async () => { + const system = platformToSystem(game.platform); + const extensions = system + ? await globalThis.window.electron.getEmulatorRomExtensions(system) + : ["*"]; + + if (!cancelled) { + setDiscPickerFilters([ + { name: t("rom_file"), extensions }, + { name: t("all_files"), extensions: ["*"] }, + ]); + } + }; + + void loadDiscFilters(); + + return () => { + cancelled = true; + }; + }, [game, t, visible]); + useEffect(() => { if (!visible) return; persistedLaunchOptionsRef.current = game?.launchOptions ?? ""; @@ -304,24 +362,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( @@ -383,68 +427,52 @@ 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 filters = getGameExecutableFilters( - globalThis.window.electron.platform, - { + const execPickerFilters = useMemo( + () => + getGameExecutableFilters(globalThis.window.electron.platform, { executable: t("game_executable"), allFiles: t("all_files"), - } - ); - - const { filePaths } = await globalThis.window.electron.showOpenDialog({ - properties: ["openFile"], - defaultPath: downloadsPath, - filters, - }); - - if (filePaths && filePaths.length > 0) { - return filePaths[0]; - } + }), + [t] + ); - return null; - }, [getDownloadsPath, t]); + const assetPickerFilters = useMemo( + () => [ + { + name: t("edit_game_modal_image_filter", { ns: "sidebar" }), + extensions: [...IMAGE_FILE_EXTENSIONS], + }, + ], + [t] + ); - 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; @@ -588,23 +616,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; @@ -671,7 +688,10 @@ export function useGameSettingsModalState({ steamShortcutExists, shouldShowCreateStartMenuShortcut: globalThis.window.electron.platform === "win32", - onChangeExecutableLocation: handleChangeExecutableLocation, + execPickerInitialPath, + execPickerFilters, + discPickerFilters, + onProcessExecPath: handleProcessExecPath, onClearExecutablePath: handleClearExecutablePath, onOpenSaveFolder: handleOpenSaveFolder, onChangeLaunchOptions: setLaunchOptions, @@ -682,22 +702,25 @@ export function useGameSettingsModalState({ onDeleteSteamShortcut: handleDeleteSteamShortcut, onSelectDisc: handleSelectDisc, onToggleDontAskDiscSelection: handleToggleDontAskDiscSelection, - onAddDiscFile: handleAddDiscFile, + onProcessDiscPath: handleProcessDiscPath, onRemoveSelectedDisc: handleRemoveSelectedDisc, onRemoveAllDiscs: handleRemoveAllDiscs, } satisfies GameLaunchSettingsProps; }, [ creatingSteamShortcut, + discPickerFilters, + execPickerFilters, + execPickerInitialPath, game, - handleAddDiscFile, handleBlurLaunchOptions, - handleChangeExecutableLocation, + handleProcessExecPath, handleClearExecutablePath, handleClearLaunchOptions, handleCreateShortcut, handleCreateSteamShortcut, handleDeleteSteamShortcut, handleOpenSaveFolder, + handleProcessDiscPath, handleRemoveAllDiscs, handleRemoveSelectedDisc, handleSelectDisc, @@ -715,18 +738,20 @@ export function useGameSettingsModalState({ game, gameTitle, updatingGameTitle, + assetPickerFilters, onChangeGameTitle: handleChangeGameTitle, onBlurGameTitle: handleBlurGameTitle, - onSelectAsset: handleSelectCustomizationAsset, + onProcessAssetPath: handleProcessAssetPath, onClearAsset: handleClearCustomizationAsset, } satisfies GameCustomizationSettingsProps; }, [ game, gameTitle, + assetPickerFilters, handleBlurGameTitle, handleChangeGameTitle, handleClearCustomizationAsset, - handleSelectCustomizationAsset, + handleProcessAssetPath, updatingGameTitle, ]); 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 0000000000..3e5ae0bf06 --- /dev/null +++ b/src/big-picture/src/helpers/file-entry-icon.tsx @@ -0,0 +1,579 @@ +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; +} + +const iconProps = { + size: 22, + weight: "fill", +} as const; + +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", +]); + +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", + "tta", + "wv", + "ra", +]); + +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", +]); + +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", +]); + +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", +]); + +const scriptExtensions = new Set([ + "sh", + "bash", + "zsh", + "fish", + "ksh", + "csh", + "tcsh", + "cmd", + "bat", + "ps1", + "psm1", + "psd1", + "vbs", + "vbe", + "wsf", + "wsh", + "command", + "run", +]); + +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", +]); + +const patchExtensions = new Set([ + "ips", + "bps", + "ups", + "xdelta", + "xdelta3", + "vcdiff", + "ppf", + "aps", +]); + +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); +} diff --git a/src/big-picture/src/helpers/index.ts b/src/big-picture/src/helpers/index.ts index 1ec12c872b..f49c88c2c7 100644 --- a/src/big-picture/src/helpers/index.ts +++ b/src/big-picture/src/helpers/index.ts @@ -11,4 +11,5 @@ export * from "./library-game-state"; export * from "./library-toast"; export * from "./navigation"; export * from "./preferred-assets"; +export * from "./file-entry-icon"; export * from "./strings"; diff --git a/src/big-picture/src/locales/en/translation.json b/src/big-picture/src/locales/en/translation.json index e15dbc163c..2e399d2ff0 100644 --- a/src/big-picture/src/locales/en/translation.json +++ b/src/big-picture/src/locales/en/translation.json @@ -749,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 42c2e3269f..221b9a487e 100644 --- a/src/big-picture/src/locales/es/translation.json +++ b/src/big-picture/src/locales/es/translation.json @@ -738,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 d8e12b5119..2b4668d624 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 91582c25b9..0a4e322d90 100644 --- a/src/big-picture/src/locales/pt-BR/translation.json +++ b/src/big-picture/src/locales/pt-BR/translation.json @@ -743,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 8c996f92ac..dd4c2eccfa 100644 --- a/src/big-picture/src/locales/ru/translation.json +++ b/src/big-picture/src/locales/ru/translation.json @@ -757,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/big-picture/src/pages/settings/download-directories-section.tsx b/src/big-picture/src/pages/settings/download-directories-section.tsx index cf5bcd480c..61e5e62dc5 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,12 +24,14 @@ import { useMemo, useState, } from "react"; +import { useTranslation } from "react-i18next"; import { Button, ContextMenu, DropdownSelect, type DropdownSelectOption, + FileExplorerModal, GridFocusGroup, HorizontalFocusGroup, UserDiskItem, @@ -44,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 { @@ -231,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, }; } @@ -294,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< @@ -302,6 +324,7 @@ export function DownloadDirectoriesSection({ const [directoryMenu, setDirectoryMenu] = useState( null ); + const [filePickerOpen, setFilePickerOpen] = useState(false); useEffect(() => { let isMounted = true; @@ -458,27 +481,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 +671,15 @@ export function DownloadDirectoriesSection({ restoreFocusId={directoryMenu?.restoreFocusId ?? null} onClose={() => setDirectoryMenu(null)} /> + + ); } diff --git a/src/main/events/misc/file-system.ts b/src/main/events/misc/file-system.ts new file mode 100644 index 0000000000..43061c961e --- /dev/null +++ b/src/main/events/misc/file-system.ts @@ -0,0 +1,230 @@ +import { app } from "electron"; +import { access, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { platform } from "node:os"; +import { registerEvent } from "../register-event"; + +const FILE_STAT_CONCURRENCY = 32; + +interface DirectoryEntry { + name: string; + path: string; + isDirectory: boolean; + isFile: boolean; + extension: string; + size: number; +} + +interface PathInfo { + exists: boolean; + isDirectory: boolean; + 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 (!isTrustedRendererUrl(url)) { + throw new Error("Unauthorized IPC sender"); + } +} + +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 +): Promise => { + assertTrustedSender(event); + + const entries = await readdir(dirPath, { withFileTypes: true }); + + const result = await mapWithConcurrency( + entries, + FILE_STAT_CONCURRENCY, + 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 = + isDirectoryCandidate && name.toLowerCase().endsWith(".app"); + const isDirectory = isDirectoryCandidate && !isMacApp; + const isFile = stats + ? stats.isFile() || isMacApp + : entry.isFile() || isMacApp; + + let ext = ""; + if (isFile && name.includes(".")) { + ext = name.split(".").pop()?.toLowerCase() ?? ""; + } + + let size = 0; + + if (isFile) { + if (stats) { + size = stats.size; + } else { + try { + const fileStats = await stat(fullPath); + size = fileStats.size; + } catch { + // File may not be accessible + } + } + } + + return { + name, + path: fullPath, + isDirectory, + isFile, + extension: ext, + size, + }; + } + ); + + 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; +}; + +const getPathInfo = async ( + event: Electron.IpcMainInvokeEvent, + filePath: string +): Promise => { + assertTrustedSender(event); + + 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 ( + event: Electron.IpcMainInvokeEvent +): Promise => { + assertTrustedSender(event); + + if (platform() === "win32") { + const driveChecks = Array.from({ length: 26 }, (_, i) => { + const letter = String.fromCodePoint(65 + i); + const root = `${letter}:\\`; + + return access(root) + .then(() => root) + .catch(() => null); + }); + + const drives = await Promise.all(driveChecks); + return drives.filter((drive): drive is string => drive !== null); + } + + return ["/"]; +}; + +registerEvent("readDirectory", readDirectory); +registerEvent("getPathInfo", getPathInfo); +registerEvent("listDrives", listDrives); diff --git a/src/main/events/misc/index.ts b/src/main/events/misc/index.ts index bab7d8b6f2..3837498b9f 100644 --- a/src/main/events/misc/index.ts +++ b/src/main/events/misc/index.ts @@ -17,3 +17,4 @@ import "./reset-common-redist-preflight"; import "./save-temp-file"; import "./show-item-in-folder"; import "./show-open-dialog"; +import "./file-system"; diff --git a/src/preload/index.ts b/src/preload/index.ts index 38bca0be64..0f620fb05d 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,6 +865,7 @@ contextBridge.exposeInMainWorld("electron", { getCloudIframeUrl: () => ipcRenderer.invoke("getCloudIframeUrl"), showOpenDialog: (options: Electron.OpenDialogOptions) => ipcRenderer.invoke("showOpenDialog", options), + ...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 8ae4adabb5..79f5f8e955 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,6 +678,9 @@ declare global { showOpenDialog: ( options: Electron.OpenDialogOptions ) => Promise; + readDirectory: (path: string) => Promise; + getPathInfo: (path: string) => Promise; + listDrives: () => Promise; showItemInFolder: (path: string) => Promise; getImageDataUrl: (imageUrl: string) => Promise; getProcessedFriendImage: ( @@ -934,8 +952,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) => void) => void; + off: (channel: string, listener: (...args) => void) => void; } interface Window {