Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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