From eb94b70848e738e3cc6ad915ded0ab78171b835d Mon Sep 17 00:00:00 2001 From: Yurui Zhou Date: Mon, 15 Jun 2026 12:28:17 +0800 Subject: [PATCH] feat: hide dotfiles in file explorer (#1516) * feat: hide dotfiles in file explorer * refactor: rename to "hidden files" and read flag from store Rename the explorer dotfile toggle to show-semantics "hidden files" so copy and code share one cross-platform vocabulary, and stop prop-drilling the flag now that it lives in the panel store. - explorerShowHiddenFiles (default true) replaces explorerHideDotFiles; i18n keys + translations switched from "dotfiles" to "hidden files" - imperative helpers read usePanelStore.getState() instead of taking the flag as a parameter; flag stays threaded only through the reactive tree-row render path - drop dead usePanelState exposure; collapse migration double-guard * fix: address Greptile review on hidden-file path matching - isHiddenExplorerPath no longer treats ".." segments as hidden (only "." was exempt before); add tests for ".." and "../sibling" - assert the showHiddenFiles i18n key alongside hideHiddenFiles --------- Co-authored-by: Mohamed Boudra --- .../app/src/components/file-explorer-pane.tsx | 179 +++++++++++++----- .../app/src/file-explorer/visibility.test.ts | 44 +++++ packages/app/src/file-explorer/visibility.ts | 17 ++ packages/app/src/i18n/resources.test.ts | 3 + packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + packages/app/src/stores/panel-store/index.ts | 8 +- .../app/src/stores/panel-store/state.test.ts | 9 + packages/app/src/stores/panel-store/state.ts | 3 + 13 files changed, 230 insertions(+), 51 deletions(-) create mode 100644 packages/app/src/file-explorer/visibility.test.ts create mode 100644 packages/app/src/file-explorer/visibility.ts diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index c1295a541..a14954cc8 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -22,6 +22,8 @@ import { ChevronRight, Copy, Download, + Eye, + EyeOff, MoreVertical, RotateCw, } from "lucide-react-native"; @@ -43,6 +45,7 @@ import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-action import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; +import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { isWeb } from "@/constants/platform"; @@ -267,7 +270,11 @@ export function FileExplorerPane({ workspaceRoot: normalizedWorkspaceRoot, }); const sortOption = usePanelStore((state) => state.explorerSortOption); + const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); + const toggleExplorerShowHiddenFiles = usePanelStore( + (state) => state.toggleExplorerShowHiddenFiles, + ); const expandedPathsArray = usePanelStore((state) => workspaceStateKey ? state.expandedPathsByWorkspace[workspaceStateKey] : undefined, ); @@ -379,6 +386,14 @@ export function FileExplorerPane({ setSortOption(SORT_OPTIONS[nextIndex].value); }, [sortOption, setSortOption]); + const handleToggleHiddenFiles = useCallback(() => { + const willShow = !usePanelStore.getState().explorerShowHiddenFiles; + toggleExplorerShowHiddenFiles(); + if (willShow) { + requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing }); + } + }, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]); + const refreshExplorer = useCallback( () => refreshExplorerDirectories({ @@ -409,8 +424,8 @@ export function FileExplorerPane({ const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels); const treeRows = useMemo( - () => resolveTreeRows({ directories, expandedPaths, sortOption }), - [directories, expandedPaths, sortOption], + () => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }), + [directories, expandedPaths, showHiddenFiles, sortOption], ); const showInitialLoading = resolveShowInitialLoading({ @@ -483,6 +498,7 @@ export function FileExplorerPane({ scrollbar={scrollbar} renderTreeRow={renderTreeRow} handleSortCycle={handleSortCycle} + handleToggleHiddenFiles={handleToggleHiddenFiles} handleRefresh={handleRefresh} handleBackFromError={handleBackFromError} handleRetry={handleRetry} @@ -505,6 +521,7 @@ interface FileExplorerPaneContentProps { scrollbar: ReturnType; renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; + handleToggleHiddenFiles: () => void; handleRefresh: () => void; handleBackFromError: () => void; handleRetry: () => void; @@ -527,6 +544,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { scrollbar, renderTreeRow, handleSortCycle, + handleToggleHiddenFiles, handleRefresh, handleBackFromError, handleRetry, @@ -534,6 +552,26 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { iconButtonStyle: iconButtonStyleProp, } = props; + const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); + + const hiddenFilesToggleAccessibilityLabel = showHiddenFiles + ? t("workspace.fileExplorer.actions.hideHiddenFiles") + : t("workspace.fileExplorer.actions.showHiddenFiles"); + const emptyLabel = showHiddenFiles + ? t("workspace.fileExplorer.empty.noFiles") + : t("workspace.fileExplorer.empty.noVisibleFiles"); + const hiddenFilesToggleStyle = useCallback( + (state: PressableStateCallbackType) => [ + iconButtonStyleProp(state), + !showHiddenFiles && styles.iconButtonActive, + ], + [showHiddenFiles, iconButtonStyleProp], + ); + const hiddenFilesToggleAccessibilityState = useMemo( + () => ({ selected: !showHiddenFiles }), + [showHiddenFiles], + ); + if (error) { return ( @@ -561,14 +599,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { ); } - if (treeRows.length === 0) { - return ( - - {t("workspace.fileExplorer.empty.noFiles")} - - ); - } - return ( @@ -576,45 +606,67 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { {currentSortLabel} - - - {isRefreshFetching ? ( - + + + {showHiddenFiles ? ( + ) : ( - + )} - - + + + + {isRefreshFetching ? ( + + ) : ( + + )} + + + - - {scrollbar.overlay} + {treeRows.length === 0 ? ( + + {emptyLabel} + + ) : ( + + )} + {treeRows.length > 0 ? scrollbar.overlay : null} ); } @@ -643,12 +695,14 @@ function buildTreeRows({ directories, expandedPaths, sortOption, + showHiddenFiles, path, depth, }: { directories: Map; expandedPaths: Set; sortOption: SortOption; + showHiddenFiles: boolean; path: string; depth: number; }): TreeRow[] { @@ -658,7 +712,10 @@ function buildTreeRows({ } const rows: TreeRow[] = []; - const entries = sortEntries(directory.entries, sortOption); + const entries = sortEntries( + filterVisibleExplorerEntries(directory.entries, showHiddenFiles), + sortOption, + ); for (const entry of entries) { rows.push({ entry, depth }); @@ -668,6 +725,7 @@ function buildTreeRows({ directories, expandedPaths, sortOption, + showHiddenFiles, path: entry.path, depth: depth + 1, }), @@ -731,15 +789,24 @@ function resolveTreeRows({ directories, expandedPaths, sortOption, + showHiddenFiles, }: { directories: Map; expandedPaths: Set; sortOption: SortOption; + showHiddenFiles: boolean; }): TreeRow[] { if (!directories.get(".")) { return []; } - return buildTreeRows({ directories, expandedPaths, sortOption, path: ".", depth: 0 }); + return buildTreeRows({ + directories, + expandedPaths, + sortOption, + showHiddenFiles, + path: ".", + depth: 0, + }); } type StartDownloadFn = ReturnType["startDownload"]; @@ -890,12 +957,13 @@ function requestPersistedExpandedPaths({ opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, ) => Promise; }): void { + const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles; const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; if (!persistedPaths) { return; } for (const path of persistedPaths) { - if (path !== ".") { + if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) { void requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, @@ -919,7 +987,10 @@ async function refreshExplorerDirectories({ if (!hasWorkspaceScope) { return null; } - const directoryPaths = Array.from(expandedPaths); + const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles; + const directoryPaths = Array.from(expandedPaths).filter( + (path) => showHiddenFiles || !isHiddenExplorerPath(path), + ); if (!directoryPaths.includes(".")) { directoryPaths.unshift("."); } @@ -1011,6 +1082,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, treeList: { flex: 1, minHeight: 0, @@ -1154,6 +1230,9 @@ const styles = StyleSheet.create((theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + iconButtonActive: { + backgroundColor: theme.colors.surface2, + }, refreshIcon: { width: 16, height: 16, diff --git a/packages/app/src/file-explorer/visibility.test.ts b/packages/app/src/file-explorer/visibility.test.ts new file mode 100644 index 000000000..8d952b2a4 --- /dev/null +++ b/packages/app/src/file-explorer/visibility.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import type { ExplorerEntry } from "@/stores/session-store"; +import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility"; + +function makeEntry(name: string, kind: ExplorerEntry["kind"]): ExplorerEntry { + return { + name, + path: name, + kind, + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("file explorer visibility", () => { + it("keeps dot-prefixed entries when hidden files are shown", () => { + const entries = [makeEntry(".env", "file"), makeEntry("src", "directory")]; + + expect(filterVisibleExplorerEntries(entries, true)).toEqual(entries); + }); + + it("hides dot-prefixed files and directories when hidden files are not shown", () => { + const entries = [ + makeEntry(".env", "file"), + makeEntry(".git", "directory"), + makeEntry("README.md", "file"), + makeEntry("src", "directory"), + ]; + + expect(filterVisibleExplorerEntries(entries, false).map((entry) => entry.name)).toEqual([ + "README.md", + "src", + ]); + }); + + it("detects paths nested under dot-prefixed directories", () => { + expect(isHiddenExplorerPath(".")).toBe(false); + expect(isHiddenExplorerPath("..")).toBe(false); + expect(isHiddenExplorerPath("../sibling")).toBe(false); + expect(isHiddenExplorerPath("src/components")).toBe(false); + expect(isHiddenExplorerPath(".git")).toBe(true); + expect(isHiddenExplorerPath("src/.cache/output.json")).toBe(true); + }); +}); diff --git a/packages/app/src/file-explorer/visibility.ts b/packages/app/src/file-explorer/visibility.ts new file mode 100644 index 000000000..7c8a616a3 --- /dev/null +++ b/packages/app/src/file-explorer/visibility.ts @@ -0,0 +1,17 @@ +import type { ExplorerEntry } from "@/stores/session-store"; + +export function isHiddenExplorerPath(path: string): boolean { + return path + .split("/") + .some((segment) => segment !== "." && segment !== ".." && segment.startsWith(".")); +} + +export function filterVisibleExplorerEntries( + entries: ExplorerEntry[], + showHiddenFiles: boolean, +): ExplorerEntry[] { + if (showHiddenFiles) { + return entries; + } + return entries.filter((entry) => !entry.name.startsWith(".")); +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 7283edf83..1fc037945 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -224,7 +224,10 @@ describe("translation resources", () => { expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions"); expect(en.importSession.actions.refresh).toBe("Refresh sessions"); expect(en.workspace.fileExplorer.sort.name).toBe("Name"); + expect(en.workspace.fileExplorer.actions.hideHiddenFiles).toBe("Hide hidden files"); + expect(en.workspace.fileExplorer.actions.showHiddenFiles).toBe("Show hidden files"); expect(en.workspace.fileExplorer.empty.noFiles).toBe("No files"); + expect(en.workspace.fileExplorer.empty.noVisibleFiles).toBe("No visible files"); expect(en.workspace.setup.status.running).toBe("Running"); expect(en.workspace.setup.empty.noCommands).toBe("No setup commands ran for this workspace."); expect(en.workspace.browser.unavailable.title).toBe("Browser is desktop-only"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5ed79e4ee..28ff92513 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -355,9 +355,12 @@ export const ar: TranslationResources = { retry: "أعد المحاولة", refresh: "تحديث الملفات", refreshing: "تحديث الملفات", + hideHiddenFiles: "إخفاء الملفات المخفية", + showHiddenFiles: "إظهار الملفات المخفية", }, empty: { noFiles: "لا توجد ملفات", + noVisibleFiles: "لا توجد ملفات مرئية", }, states: { unavailable: "Workspace غير متوفر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d2a186cf4..3962b4be5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -354,9 +354,12 @@ export const en = { retry: "Retry", refresh: "Refresh files", refreshing: "Refreshing files", + hideHiddenFiles: "Hide hidden files", + showHiddenFiles: "Show hidden files", }, empty: { noFiles: "No files", + noVisibleFiles: "No visible files", }, states: { unavailable: "Workspace is unavailable", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b374b73b6..b2e0d19cf 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -358,9 +358,12 @@ export const es: TranslationResources = { retry: "Rever", refresh: "Actualizar archivos", refreshing: "Actualizar archivos", + hideHiddenFiles: "Ocultar archivos ocultos", + showHiddenFiles: "Mostrar archivos ocultos", }, empty: { noFiles: "Sin archivos", + noVisibleFiles: "No hay archivos visibles", }, states: { unavailable: "Workspaceno está disponible", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 192a1ac65..955ec8b41 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -358,9 +358,12 @@ export const fr: TranslationResources = { retry: "Réessayer", refresh: "Actualiser les fichiers", refreshing: "Actualisation des fichiers", + hideHiddenFiles: "Masquer les fichiers cachés", + showHiddenFiles: "Afficher les fichiers cachés", }, empty: { noFiles: "Aucun fichier", + noVisibleFiles: "Aucun fichier visible", }, states: { unavailable: "Workspacen'est pas disponible", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 84b08c222..443c57d80 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -357,9 +357,12 @@ export const ru: TranslationResources = { retry: "Повторить попытку", refresh: "Обновить файлы", refreshing: "Обновление файлов", + hideHiddenFiles: "Скрыть скрытые файлы", + showHiddenFiles: "Показать скрытые файлы", }, empty: { noFiles: "Нет файлов", + noVisibleFiles: "Нет видимых файлов", }, states: { unavailable: "Workspace недоступен", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 41a74df7d..c655b915c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -355,9 +355,12 @@ export const zhCN: TranslationResources = { retry: "重试", refresh: "刷新文件", refreshing: "正在刷新文件", + hideHiddenFiles: "隐藏隐藏文件", + showHiddenFiles: "显示隐藏文件", }, empty: { noFiles: "没有文件", + noVisibleFiles: "没有可见文件", }, states: { unavailable: "Workspace 不可用", diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 8be293ef3..3c04440ba 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -75,6 +75,7 @@ export interface PanelState { sidebarWidth: number; explorerWidth: number; explorerSortOption: SortOption; + explorerShowHiddenFiles: boolean; explorerFilesSplitRatio: number; // Actions @@ -101,6 +102,7 @@ export interface PanelState { setSidebarWidth: (width: number) => void; setExplorerWidth: (width: number) => void; setExplorerSortOption: (option: SortOption) => void; + toggleExplorerShowHiddenFiles: () => void; setExplorerFilesSplitRatio: (ratio: number) => void; } @@ -127,6 +129,7 @@ export const usePanelStore = create()( sidebarWidth: DEFAULT_SIDEBAR_WIDTH, explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, explorerSortOption: "name", + explorerShowHiddenFiles: true, explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, toggleFocusMode: () => @@ -262,6 +265,8 @@ export const usePanelStore = create()( setSidebarWidth: (width) => set({ sidebarWidth: clampSidebarWidth(width) }), setExplorerWidth: (width) => set({ explorerWidth: clampExplorerWidth(width) }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), + toggleExplorerShowHiddenFiles: () => + set((state) => ({ explorerShowHiddenFiles: !state.explorerShowHiddenFiles })), setExplorerFilesSplitRatio: (ratio) => set({ explorerFilesSplitRatio: Number.isFinite(ratio) @@ -271,7 +276,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 10, + version: 11, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, @@ -285,6 +290,7 @@ export const usePanelStore = create()( sidebarWidth: state.sidebarWidth, explorerWidth: state.explorerWidth, explorerSortOption: state.explorerSortOption, + explorerShowHiddenFiles: state.explorerShowHiddenFiles, explorerFilesSplitRatio: state.explorerFilesSplitRatio, }), }, diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index 0d2764574..538f58900 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -7,6 +7,7 @@ import { import { buildOpenFileExplorerPatch, buildToggleFileExplorerPatch, + migratePanelState, selectIsAgentListOpen, selectIsFileExplorerOpen, selectPanelVisibility, @@ -96,6 +97,14 @@ describe("panel-store explorer tab resolution", () => { }); }); +describe("panel-store migration", () => { + it("defaults hidden-file visibility to showing hidden files", () => { + const state = migratePanelState({}, 10, { isWeb: false }); + + expect(state.explorerShowHiddenFiles).toBe(true); + }); +}); + describe("panel-store visibility selectors", () => { it("uses mobileView for compact layout visibility", () => { const state = makePanelState({ diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index c8f28b2ea..1bafd89a5 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -245,6 +245,9 @@ export function migratePanelState( ) { state.diffExpandedPathsByWorkspace = {}; } + if (typeof state.explorerShowHiddenFiles !== "boolean") { + state.explorerShowHiddenFiles = true; + } return state; }