diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index ea2a80010124..6c218f0f2380 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -66,6 +66,7 @@ const clientSettings: ClientSettings = {
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
pullRequestMergeMethodOverrides: {},
timestampFormat: "24-hour",
+ showGitignoredFiles: true,
wordWrap: true,
};
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 3be551d0604b..90e15c8338f8 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,3 +1,4 @@
+import { useAtomValue } from "@effect/atom-react";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import type { MenuAction } from "@react-native-menu/menu";
@@ -36,6 +37,7 @@ import { useMediaActions, type MediaActionsSource } from "../../lib/mediaActions
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useEnvironmentQuery } from "../../state/query";
+import { showGitignoredFilesAtom } from "../../state/preferences";
import { projectEnvironment } from "../../state/projects";
import type { AssetUrlFailureReason } from "../../state/asset-url-state";
import {
@@ -305,6 +307,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const { fileInspector, layout, panes, showAuxiliaryPane, togglePrimarySidebar } =
useAdaptiveWorkspaceLayout();
const [searchQuery, setSearchQuery] = useState("");
+ const includeIgnored = useAtomValue(showGitignoredFilesAtom);
const isAndroid = Platform.OS === "android";
const { themeAppearance: highlightTheme, materialYouStyleLayoutActive } =
useAppearancePreferences();
@@ -319,7 +322,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
environmentId !== null && cwd !== null && !fileInspector.supported
? projectEnvironment.listEntries({
environmentId,
- input: { cwd },
+ input: { cwd, includeIgnored },
})
: null,
);
diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
index 33b99dd8e8ce..fd4318119310 100644
--- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
+++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx
@@ -1,3 +1,4 @@
+import { useAtomValue } from "@effect/atom-react";
import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useMemo, useState, type ComponentProps } from "react";
@@ -13,6 +14,7 @@ import {
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useUniwindTheme } from "../../lib/useUniwindTheme";
+import { showGitignoredFilesAtom } from "../../state/preferences";
import { projectEnvironment } from "../../state/projects";
import { useEnvironmentQuery } from "../../state/query";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
@@ -28,6 +30,7 @@ export function ThreadFileNavigatorPane(props: {
readonly onSelectFile: (path: string) => void;
}) {
const [searchQuery, setSearchQuery] = useState("");
+ const includeIgnored = useAtomValue(showGitignoredFilesAtom);
const { themeAppearance: highlightTheme } = useAppearancePreferences();
const theme = useUniwindTheme();
const foregroundColor = theme["--color-foreground"];
@@ -36,7 +39,7 @@ export function ThreadFileNavigatorPane(props: {
const entriesQuery = useEnvironmentQuery(
projectEnvironment.listEntries({
environmentId: props.environmentId,
- input: { cwd: props.cwd },
+ input: { cwd: props.cwd, includeIgnored },
}),
);
const entriesData = entriesQuery.data as ProjectListEntriesResult | null;
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 77036c212517..60b339d0b0b2 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -33,7 +33,11 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { runtime } from "../../lib/runtime";
-import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
+import {
+ mobilePreferencesAtom,
+ showGitignoredFilesAtom,
+ updateMobilePreferencesAtom,
+} from "../../state/preferences";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { useEnvironments } from "../../state/environments";
@@ -576,8 +580,17 @@ function ConfiguredSettingsRouteScreen() {
}
function GeneralSettingsSection() {
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const showGitignoredFiles = useAtomValue(showGitignoredFilesAtom);
return (
+ savePreferences({ showGitignoredFiles: value })}
+ />
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 8209f6103bcc..ce32526ed211 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -27,6 +27,7 @@ export interface Preferences {
readonly markdownFontSize?: number;
readonly codeFontSize?: number | null;
readonly codeWordBreak?: boolean;
+ readonly showGitignoredFiles?: boolean;
readonly connectOnboardingOptOutAccounts?: ReadonlyArray;
readonly collapsedProjectGroups?: readonly string[];
/** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
@@ -97,6 +98,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
markdownFontSize?: number;
codeFontSize?: number | null;
codeWordBreak?: boolean;
+ showGitignoredFiles?: boolean;
connectOnboardingOptOutAccounts?: ReadonlyArray;
collapsedProjectGroups?: readonly string[];
projectGroupingEnabled?: boolean;
@@ -172,6 +174,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
+ if (typeof parsed.showGitignoredFiles === "boolean") {
+ preferences.showGitignoredFiles = parsed.showGitignoredFiles;
+ }
if (typeof parsed.planModeEnabled === "boolean") {
preferences.planModeEnabled = parsed.planModeEnabled;
}
diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts
index d173cf55be5a..1e47e566185c 100644
--- a/apps/mobile/src/state/preferences.ts
+++ b/apps/mobile/src/state/preferences.ts
@@ -122,3 +122,8 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere
export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom;
export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;
+
+export const showGitignoredFilesAtom = Atom.make((get) => {
+ const preferences = get(mobilePreferencesAtom);
+ return AsyncResult.isSuccess(preferences) && preferences.value.showGitignoredFiles === true;
+});
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
index 01f2dd96b18f..7a781ef730c1 100644
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -6790,6 +6790,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
"export const answer = 42;\n",
);
+ yield* fs.makeDirectory(path.join(workspaceDir, "node_modules"));
+ yield* fs.writeFileString(path.join(workspaceDir, "node_modules", "ignored.js"), "");
+
yield* buildAppUnderTest();
const wsUrl = yield* getWsServerUrl("/ws");
@@ -6797,6 +6800,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
withWsRpcClient(wsUrl, (client) =>
Effect.all({
listing: client[WS_METHODS.projectsListEntries]({ cwd: workspaceDir }),
+ includingIgnored: client[WS_METHODS.projectsListEntries]({
+ cwd: workspaceDir,
+ includeIgnored: true,
+ }),
file: client[WS_METHODS.projectsReadFile]({
cwd: workspaceDir,
relativePath: "src/index.ts",
@@ -6806,6 +6813,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
);
assert.isTrue(response.listing.entries.some((entry) => entry.path === "src/index.ts"));
+ assert.isFalse(
+ response.listing.entries.some((entry) => entry.path === "node_modules/ignored.js"),
+ );
+ assert.isTrue(
+ response.includingIgnored.entries.some((entry) => entry.path === "node_modules/ignored.js"),
+ );
assert.deepEqual(response.file, {
relativePath: "src/index.ts",
contents: "export const answer = 42;\n",
diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts
index ff3343c37138..33f877e87662 100644
--- a/apps/server/src/workspace/WorkspaceEntries.test.ts
+++ b/apps/server/src/workspace/WorkspaceEntries.test.ts
@@ -96,6 +96,87 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
});
describe("list", () => {
+ it.effect(
+ "includes ignored files on request without changing filtered listings or search",
+ () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTempDir({ git: true });
+ yield* writeTextFile(cwd, ".gitignore", "build/\nignored.txt\n");
+ yield* writeTextFile(cwd, "src/index.ts");
+ yield* writeTextFile(cwd, "build/output.js");
+ yield* writeTextFile(cwd, "ignored.txt");
+ yield* writeTextFile(cwd, "node_modules/pkg/index.js");
+ const service = yield* WorkspaceEntries.WorkspaceEntries;
+ const before = yield* service.list({ cwd });
+ const included = yield* service.list({ cwd, includeIgnored: true });
+ expect(included.entries).toEqual(
+ expect.arrayContaining([
+ { path: "src/index.ts", kind: "file" },
+ { path: "build", kind: "directory" },
+ { path: "build/output.js", kind: "file" },
+ { path: "ignored.txt", kind: "file" },
+ { path: "node_modules/pkg/index.js", kind: "file" },
+ ]),
+ );
+ expect(
+ included.entries.some(
+ (entry) => entry.path === ".git" || entry.path.startsWith(".git/"),
+ ),
+ ).toBe(false);
+ expect(included.truncated).toBe(false);
+ const after = yield* service.list({ cwd, includeIgnored: false });
+ expect(after).toEqual(before);
+ expect(
+ after.entries.some(
+ (entry) => entry.path === "ignored.txt" || entry.path.startsWith("build/"),
+ ),
+ ).toBe(false);
+ expect(
+ (yield* service.search({ cwd, query: "ignored", limit: 10 })).entries.map(
+ (entry) => entry.path,
+ ),
+ ).not.toContain("ignored.txt");
+ yield* writeTextFile(cwd, "build/next.js");
+ expect((yield* service.list({ cwd, includeIgnored: true })).entries).toContainEqual({
+ path: "build/next.js",
+ kind: "file",
+ });
+ }),
+ );
+
+ it.effect("includes ignored directories outside git without traversing symlinks", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTempDir();
+ yield* writeTextFile(cwd, "node_modules/pkg/index.js");
+ yield* Effect.promise(() => NodeFSP.symlink(cwd, `${cwd}/loop`, "junction"));
+ const service = yield* WorkspaceEntries.WorkspaceEntries;
+ const result = yield* service.list({ cwd, includeIgnored: true });
+ expect(result.entries).toContainEqual({ path: "node_modules/pkg/index.js", kind: "file" });
+ expect(result.entries).toContainEqual({ path: "loop", kind: "file" });
+ expect(result.entries.some((entry) => entry.path.startsWith("loop/"))).toBe(false);
+ expect(result.truncated).toBe(false);
+ }),
+ );
+
+ it.effect("bounds the ignored-file listing and reports truncation", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTempDir();
+ yield* Effect.promise(async () => {
+ for (let start = 0; start < 25_001; start += 100) {
+ await Promise.all(
+ Array.from({ length: Math.min(100, 25_001 - start) }, (_, offset) =>
+ NodeFSP.writeFile(`${cwd}/file-${start + offset}.txt`, ""),
+ ),
+ );
+ }
+ });
+ const service = yield* WorkspaceEntries.WorkspaceEntries;
+ const result = yield* service.list({ cwd, includeIgnored: true });
+ expect(result.entries).toHaveLength(25_000);
+ expect(result.truncated).toBe(true);
+ }),
+ );
+
it.effect("returns the complete cached workspace index", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir();
diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts
index 4bdf4d45a8c9..0e51f5b37699 100644
--- a/apps/server/src/workspace/WorkspaceEntries.ts
+++ b/apps/server/src/workspace/WorkspaceEntries.ts
@@ -11,6 +11,7 @@ import * as Schema from "effect/Schema";
import type {
FilesystemBrowseInput,
FilesystemBrowseResult,
+ ProjectEntry,
ProjectListEntriesInput,
ProjectListEntriesResult,
ProjectSearchContentsInput,
@@ -74,6 +75,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([
export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type;
export const WorkspaceEntriesError = Schema.Union([
+ WorkspaceEntriesReadDirectoryError,
WorkspacePaths.WorkspaceRootNotExistsError,
WorkspacePaths.WorkspaceRootCreateFailedError,
WorkspacePaths.WorkspaceRootStatFailedError,
@@ -128,6 +130,61 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu
return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath);
});
+// The search index always applies ignore rules. Only the opt-in explorer
+// listing walks the filesystem breadth-first, listing top-level directories
+// before their descendants. Never descend into symlinks or .git.
+const listIncludingIgnored = Effect.fn("WorkspaceEntries.listIncludingIgnored")(
+ (cwd: string, path: Path.Path) =>
+ Effect.tryPromise({
+ try: async (signal) => {
+ const entries: ProjectEntry[] = [];
+ const directories = [""];
+ let truncated = false;
+ scan: for (let index = 0; index < directories.length; index++) {
+ signal.throwIfAborted();
+ const parent = directories[index]!;
+ const directory = await NodeFSP.opendir(path.join(cwd, parent));
+ for await (const dirent of directory) {
+ signal.throwIfAborted();
+ if (dirent.name === ".git") continue;
+ if (!dirent.isFile() && !dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
+ if (entries.length === 25_000) {
+ truncated = true;
+ break scan;
+ }
+ const relativePath = parent ? `${parent}/${dirent.name}` : dirent.name;
+ entries.push({ path: relativePath, kind: dirent.isDirectory() ? "directory" : "file" });
+ if (dirent.isDirectory()) directories.push(relativePath);
+ }
+ }
+ return {
+ entries: entries.sort((left, right) => left.path.localeCompare(right.path)),
+ truncated,
+ };
+ },
+ catch: (cause) =>
+ new WorkspaceEntriesReadDirectoryError({
+ cwd,
+ partialPath: ".",
+ parentPath: cwd,
+ cause,
+ }),
+ }).pipe(
+ Effect.timeoutOrElse({
+ duration: "15 seconds",
+ orElse: () =>
+ Effect.fail(
+ new WorkspaceEntriesReadDirectoryError({
+ cwd,
+ partialPath: ".",
+ parentPath: cwd,
+ cause: "Directory listing timed out after 15 seconds.",
+ }),
+ ),
+ }),
+ ),
+);
+
/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const path = yield* Path.Path;
@@ -266,6 +323,7 @@ export const make = Effect.gen(function* () {
const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")(
function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
+ if (input.includeIgnored) return yield* listIncludingIgnored(normalizedCwd, path);
return yield* Effect.gen(function* () {
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
return yield* searchIndex.list();
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 5e4ca0a18db9..659b295851a6 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -229,6 +229,11 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr
failure: "workspace_root_not_directory",
normalizedCwd: error.normalizedWorkspaceRoot,
};
+ case "WorkspaceEntriesReadDirectoryError":
+ return {
+ failure: "workspace_read_directory_failed",
+ ...(error.cwd ? { normalizedCwd: error.cwd } : {}),
+ };
case "WorkspaceSearchIndexCreateFailed":
return {
failure: "search_index_create_failed",
diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx
index 8edf982a030a..06f7a71ede12 100644
--- a/apps/web/src/components/files/projectFilesQueryState.test.tsx
+++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx
@@ -1,5 +1,6 @@
import {
EnvironmentId,
+ type ClientSettings,
type ProjectListEntriesResult,
type ProjectReadFileResult,
} from "@t3tools/contracts";
@@ -13,6 +14,8 @@ const projectMocks = vi.hoisted(() => ({
readFile: vi.fn(),
}));
+const clientSettings = vi.hoisted(() => ({ showGitignoredFiles: false }));
+
const atomHooks = vi.hoisted(() => ({
registry: null as {
get(atom: object): unknown;
@@ -66,6 +69,14 @@ vi.mock("react", async (importOriginal) => {
};
});
+vi.mock("~/hooks/useSettings", async () => {
+ const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts");
+ return {
+ useClientSettings: (select: (settings: ClientSettings) => A) =>
+ select({ ...DEFAULT_CLIENT_SETTINGS, ...clientSettings }),
+ };
+});
+
vi.mock("~/state/projects", () => ({
projectEnvironment: projectMocks,
}));
@@ -115,6 +126,7 @@ describe("project query refresh", () => {
projectMocks.optimisticFile.mockReset();
projectMocks.readFile.mockReset();
reactHooks.reset();
+ clientSettings.showGitignoredFiles = false;
});
it("replaces an in-flight initial read when a workspace mutation arrives", async () => {
@@ -169,54 +181,62 @@ describe("project query refresh", () => {
}
});
- it("revalidates cached entries when a workspace mutation is observed after mounting", async () => {
- const requests: Array>> = [];
- const entriesAtom = Atom.make(
- Effect.promise(() => {
- const request = deferred();
- requests.push(request);
- return request.promise;
- }),
- ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
- const registry = AtomRegistry.make();
- const unmount = registry.mount(entriesAtom);
- projectMocks.listEntries.mockReturnValue(entriesAtom);
- atomHooks.registry = registry;
- let renderedPaths: readonly string[] = [];
+ it.each([false, true])(
+ "revalidates cached entries after a workspace mutation with ignored files %s",
+ async (showGitignoredFiles) => {
+ clientSettings.showGitignoredFiles = showGitignoredFiles;
+ const requests: Array>> = [];
+ const entriesAtom = Atom.make(
+ Effect.promise(() => {
+ const request = deferred();
+ requests.push(request);
+ return request.promise;
+ }),
+ ).pipe(Atom.swr({ staleTime: 30_000, revalidateOnMount: true }));
+ const registry = AtomRegistry.make();
+ const unmount = registry.mount(entriesAtom);
+ projectMocks.listEntries.mockReturnValue(entriesAtom);
+ atomHooks.registry = registry;
+ let renderedPaths: readonly string[] = [];
- const render = (mutationId: string | null) => {
- reactHooks.beginRender();
- const query = useProjectEntriesQuery(environmentId, "/repo");
- renderedPaths = query.data?.entries.map((entry) => entry.path) ?? [];
- useWorkspaceMutationRefresh({
- mutationId,
- refresh: query.refresh,
- resourceKey: "files:environment-1:/repo",
- });
- };
+ const render = (mutationId: string | null) => {
+ reactHooks.beginRender();
+ const query = useProjectEntriesQuery(environmentId, "/repo");
+ expect(projectMocks.listEntries).toHaveBeenLastCalledWith({
+ environmentId,
+ input: { cwd: "/repo", includeIgnored: showGitignoredFiles },
+ });
+ renderedPaths = query.data?.entries.map((entry) => entry.path) ?? [];
+ useWorkspaceMutationRefresh({
+ mutationId,
+ refresh: query.refresh,
+ resourceKey: "files:environment-1:/repo",
+ });
+ };
- try {
- await flushEffects();
- expect(requests).toHaveLength(1);
- requests[0]!.resolve(projectEntries(["src/old.ts"]));
- await flushEffects();
+ try {
+ await flushEffects();
+ expect(requests).toHaveLength(1);
+ requests[0]!.resolve(projectEntries(["src/old.ts"]));
+ await flushEffects();
- render("mutation-1");
- expect(renderedPaths).toEqual(["src/old.ts"]);
- await flushEffects();
- expect(requests).toHaveLength(2);
+ render("mutation-1");
+ expect(renderedPaths).toEqual(["src/old.ts"]);
+ await flushEffects();
+ expect(requests).toHaveLength(2);
- requests[1]!.resolve(projectEntries(["src/new.ts"]));
- await flushEffects();
- render("mutation-1");
- expect(renderedPaths).toEqual(["src/new.ts"]);
- expect(requests).toHaveLength(2);
- } finally {
- unmount();
- registry.dispose();
- atomHooks.registry = null;
- }
- });
+ requests[1]!.resolve(projectEntries(["src/new.ts"]));
+ await flushEffects();
+ render("mutation-1");
+ expect(renderedPaths).toEqual(["src/new.ts"]);
+ expect(requests).toHaveLength(2);
+ } finally {
+ unmount();
+ registry.dispose();
+ atomHooks.registry = null;
+ }
+ },
+ );
it("does not issue a file read for a disabled image preview", async () => {
const requests: Array>> = [];
diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts
index a12772920956..b911c292af16 100644
--- a/apps/web/src/components/files/projectFilesQueryState.ts
+++ b/apps/web/src/components/files/projectFilesQueryState.ts
@@ -13,6 +13,7 @@ import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback } from "react";
+import { useClientSettings } from "~/hooks/useSettings";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { projectEnvironment } from "~/state/projects";
import { useProjectPathSearch } from "~/state/queries";
@@ -33,8 +34,12 @@ interface ProjectQueryState {
readonly refresh: () => void;
}
-function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) {
- return projectEnvironment.listEntries({ environmentId, input: { cwd } });
+function getProjectEntriesQueryAtom(
+ environmentId: EnvironmentId,
+ cwd: string,
+ includeIgnored: boolean,
+) {
+ return projectEnvironment.listEntries({ environmentId, input: { cwd, includeIgnored } });
}
export function getProjectFileQueryAtom(
@@ -129,7 +134,8 @@ export function useProjectEntriesQuery(
environmentId: EnvironmentId,
cwd: string,
): ProjectQueryState {
- const atom = getProjectEntriesQueryAtom(environmentId, cwd);
+ const includeIgnored = useClientSettings((settings) => settings.showGitignoredFiles);
+ const atom = getProjectEntriesQueryAtom(environmentId, cwd, includeIgnored);
const result = useAtomValue(atom);
const refreshAtom = useAtomRefresh(atom);
const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 4c108b01d0c8..70a95694183a 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -530,6 +530,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
? ["Auto-settle merged threads"]
: []),
+ ...(settings.showGitignoredFiles !== DEFAULT_UNIFIED_SETTINGS.showGitignoredFiles
+ ? ["Show gitignored files"]
+ : []),
...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
...getChangedTypographySettingLabels(settings),
...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
@@ -630,6 +633,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
settings.timestampFormat,
+ settings.showGitignoredFiles,
settings.wordWrap,
followSystem,
theme,
@@ -702,6 +706,7 @@ export function useSettingsRestore(onRestored?: () => void) {
updateSettings({
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
+ showGitignoredFiles: DEFAULT_UNIFIED_SETTINGS.showGitignoredFiles,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
@@ -2190,6 +2195,31 @@ export function GeneralSettingsPanel() {
+
+ updateSettings({
+ showGitignoredFiles: DEFAULT_UNIFIED_SETTINGS.showGitignoredFiles,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ showGitignoredFiles: Boolean(checked) })
+ }
+ aria-label="Show gitignored files"
+ />
+ }
+ />
= [
];
describe("searchSettings", () => {
+ it.each(["gitignored", ".gitignore", "ignored files", "file explorer", "node_modules"])(
+ "finds the ignored-file setting by %s",
+ (query) => {
+ expect(searchSettings(query)[0]).toMatchObject({
+ id: "show-gitignored-files",
+ to: "/settings/general",
+ });
+ },
+ );
+
it("matches titles, sections, and remembered setting details", () => {
expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
@@ -55,7 +65,7 @@ describe("searchSettings", () => {
expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");
const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
try {
- expect(searchSettings("GIT")[0]?.id).toBe("git-fetch-interval");
+ expect(searchSettings("GIT").map((item) => item.id)).toContain("git-fetch-interval");
expect(localeLowerCase).not.toHaveBeenCalled();
} finally {
localeLowerCase.mockRestore();
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index e32baf6f3987..5cd6afaf7878 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -184,6 +184,12 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["thread timeout activity sidebar"],
requiresThreadAutoSettlement: true,
},
+ {
+ id: "show-gitignored-files",
+ title: "Show gitignored files",
+ to: "/settings/general",
+ searchTerms: ["file explorer ignored .gitignore hidden files directories node_modules"],
+ },
{
id: "time-format",
title: "Time format",
diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts
index bee2e70d5746..85e7812b6797 100644
--- a/packages/contracts/src/project.ts
+++ b/packages/contracts/src/project.ts
@@ -72,6 +72,7 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ
export const ProjectListEntriesInput = Schema.Struct({
cwd: TrimmedNonEmptyString,
+ includeIgnored: Schema.optionalKey(Schema.Boolean),
});
export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type;
@@ -89,6 +90,7 @@ export const ProjectEntriesFailure = Schema.Literals([
"search_index_create_failed",
"search_index_scan_timed_out",
"search_index_search_failed",
+ "workspace_read_directory_failed",
]);
export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type;
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index 7d3cd2ceafe7..d2ce275c5aee 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -742,3 +742,17 @@ describe("ServerSettings environment icon", () => {
expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
});
});
+
+describe("ClientSettings ignored files", () => {
+ it("defaults off and persists both toggle states", () => {
+ expect(decodeClientSettings({}).showGitignoredFiles).toBe(false);
+ for (const showGitignoredFiles of [true, false]) {
+ expect(
+ encodeClientSettings(decodeClientSettings({ showGitignoredFiles })).showGitignoredFiles,
+ ).toBe(showGitignoredFiles);
+ expect(decodeClientSettingsPatch({ showGitignoredFiles }).showGitignoredFiles).toBe(
+ showGitignoredFiles,
+ );
+ }
+ });
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 3491103da94f..c06751c685d9 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -445,6 +445,7 @@ export const ClientSettingsSchema = Schema.Struct({
),
snapShotFlash: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
snapShotAnimations: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
+ showGitignoredFiles: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
});
export type ClientSettings = typeof ClientSettingsSchema.Type;
@@ -1370,6 +1371,7 @@ export const ClientSettingsPatch = Schema.Struct({
snapShotSound: Schema.optionalKey(SnapShotSound),
snapShotFlash: Schema.optionalKey(Schema.Boolean),
snapShotAnimations: Schema.optionalKey(Schema.Boolean),
+ showGitignoredFiles: Schema.optionalKey(Schema.Boolean),
wordWrap: Schema.optionalKey(Schema.Boolean),
});
export type ClientSettingsPatch = typeof ClientSettingsPatch.Type;