Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const clientSettings: ClientSettings = {
loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
pullRequestMergeMethodOverrides: {},
timestampFormat: "24-hour",
showGitignoredFiles: true,
wordWrap: true,
};

Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand All @@ -319,7 +322,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
environmentId !== null && cwd !== null && !fileInspector.supported
? projectEnvironment.listEntries({
environmentId,
input: { cwd },
input: { cwd, includeIgnored },
})
: null,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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"];
Expand All @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -576,8 +580,17 @@ function ConfiguredSettingsRouteScreen() {
}

function GeneralSettingsSection() {
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const showGitignoredFiles = useAtomValue(showGitignoredFilesAtom);
return (
<SettingsSection title="General">
<SettingsSwitchRow
icon="folder"
label="Show gitignored files"
subtitle="Include gitignored files in the file explorer."
value={showGitignoredFiles}
onValueChange={(value) => savePreferences({ showGitignoredFiles: value })}
/>
<SettingsRow icon="folder" label="Project Grouping" target="SettingsProjectGrouping" />
<AutoSettleSettingsRows />
<SettingsRow icon="chart.bar.xaxis" label="Usage" target="SettingsUsage" />
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/persistence/mobile-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Preferences {
readonly markdownFontSize?: number;
readonly codeFontSize?: number | null;
readonly codeWordBreak?: boolean;
readonly showGitignoredFiles?: boolean;
readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
readonly collapsedProjectGroups?: readonly string[];
/** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */
Expand Down Expand Up @@ -97,6 +98,7 @@ function sanitizePreferences(parsed: Preferences): Preferences {
markdownFontSize?: number;
codeFontSize?: number | null;
codeWordBreak?: boolean;
showGitignoredFiles?: boolean;
connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
collapsedProjectGroups?: readonly string[];
projectGroupingEnabled?: boolean;
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/state/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
13 changes: 13 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6790,13 +6790,20 @@ 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");
const response = yield* Effect.scoped(
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",
Expand All @@ -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",
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema";
import type {
FilesystemBrowseInput,
FilesystemBrowseResult,
ProjectEntry,
ProjectListEntriesInput,
ProjectListEntriesResult,
ProjectSearchContentsInput,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading