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;