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 <boudra.moha@gmail.com>
This commit is contained in:
Yurui Zhou
2026-06-15 12:28:17 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent f4450cda51
commit eb94b70848
13 changed files with 230 additions and 51 deletions
@@ -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<typeof useWebScrollViewScrollbar>;
renderTreeRow: (info: ListRenderItemInfo<TreeRow>) => 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 (
<View style={styles.centerState}>
@@ -561,14 +599,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
);
}
if (treeRows.length === 0) {
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{t("workspace.fileExplorer.empty.noFiles")}</Text>
</View>
);
}
return (
<View style={TREE_PANE_CONTAINER_STYLE}>
<View style={styles.paneHeader} testID="files-pane-header">
@@ -576,45 +606,67 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={styles.headerActions}>
<Pressable
onPress={handleToggleHiddenFiles}
hitSlop={8}
style={hiddenFilesToggleStyle}
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={isRefreshFetching}
hitSlop={8}
style={iconButtonStyleProp}
accessibilityRole="button"
accessibilityLabel={
isRefreshFetching
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
</View>
</View>
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
{scrollbar.overlay}
{treeRows.length === 0 ? (
<View style={styles.centerState}>
<Text style={styles.emptyText}>{emptyLabel}</Text>
</View>
) : (
<FlatList
ref={treeListRef}
style={styles.treeList}
data={treeRows}
renderItem={renderTreeRow}
keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
initialNumToRender={24}
maxToRenderPerBatch={40}
windowSize={12}
/>
)}
{treeRows.length > 0 ? scrollbar.overlay : null}
</View>
);
}
@@ -643,12 +695,14 @@ function buildTreeRows({
directories,
expandedPaths,
sortOption,
showHiddenFiles,
path,
depth,
}: {
directories: Map<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
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<string, { path: string; entries: ExplorerEntry[] }>;
expandedPaths: Set<string>;
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<typeof useDownloadStore.getState>["startDownload"];
@@ -890,12 +957,13 @@ function requestPersistedExpandedPaths({
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
) => Promise<boolean>;
}): 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,
@@ -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);
});
});
@@ -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("."));
}
+3
View File
@@ -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");
+3
View File
@@ -355,9 +355,12 @@ export const ar: TranslationResources = {
retry: "أعد المحاولة",
refresh: "تحديث الملفات",
refreshing: "تحديث الملفات",
hideHiddenFiles: "إخفاء الملفات المخفية",
showHiddenFiles: "إظهار الملفات المخفية",
},
empty: {
noFiles: "لا توجد ملفات",
noVisibleFiles: "لا توجد ملفات مرئية",
},
states: {
unavailable: "Workspace غير متوفر",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -357,9 +357,12 @@ export const ru: TranslationResources = {
retry: "Повторить попытку",
refresh: "Обновить файлы",
refreshing: "Обновление файлов",
hideHiddenFiles: "Скрыть скрытые файлы",
showHiddenFiles: "Показать скрытые файлы",
},
empty: {
noFiles: "Нет файлов",
noVisibleFiles: "Нет видимых файлов",
},
states: {
unavailable: "Workspace недоступен",
+3
View File
@@ -355,9 +355,12 @@ export const zhCN: TranslationResources = {
retry: "重试",
refresh: "刷新文件",
refreshing: "正在刷新文件",
hideHiddenFiles: "隐藏隐藏文件",
showHiddenFiles: "显示隐藏文件",
},
empty: {
noFiles: "没有文件",
noVisibleFiles: "没有可见文件",
},
states: {
unavailable: "Workspace 不可用",
+7 -1
View File
@@ -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<PanelState>()(
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<PanelState>()(
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<PanelState>()(
}),
{
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<PanelState>()(
sidebarWidth: state.sidebarWidth,
explorerWidth: state.explorerWidth,
explorerSortOption: state.explorerSortOption,
explorerShowHiddenFiles: state.explorerShowHiddenFiles,
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
}),
},
@@ -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({
@@ -245,6 +245,9 @@ export function migratePanelState(
) {
state.diffExpandedPathsByWorkspace = {};
}
if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true;
}
return state;
}