Skip to content
Merged
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
93 changes: 58 additions & 35 deletions apps/desktop/src/main/services/files/fileSearchIndexService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ type IndexedFile = {
type WorkspaceIndex = {
workspaceId: string;
rootPath: string;
includeIgnored: boolean;
files: Map<string, IndexedFile>;
totalContentBytes: number;
buildingPromise: Promise<void> | null;
builtAt: string | null;
};

function shouldSkipPathPrefix(relPath: string): boolean {
function shouldSkipPathPrefix(relPath: string, includeIgnored: boolean): boolean {
if (includeIgnored) return false;
return relPath === ".ade" || relPath.startsWith(".ade/");
}

Expand All @@ -50,19 +52,24 @@ async function cooperativeYield(): Promise<void> {
export function createFileSearchIndexService() {
const byWorkspace = new Map<string, WorkspaceIndex>();

const getOrCreateWorkspaceIndex = (workspaceId: string, rootPath: string): WorkspaceIndex => {
const existing = byWorkspace.get(workspaceId);
const workspaceIndexKey = (workspaceId: string, includeIgnored: boolean): string =>
`${workspaceId}::${includeIgnored ? "all" : "default"}`;

const getOrCreateWorkspaceIndex = (workspaceId: string, rootPath: string, includeIgnored: boolean): WorkspaceIndex => {
const key = workspaceIndexKey(workspaceId, includeIgnored);
const existing = byWorkspace.get(key);
if (existing && existing.rootPath === rootPath) return existing;

const next: WorkspaceIndex = {
workspaceId,
rootPath,
includeIgnored,
files: new Map(),
totalContentBytes: 0,
buildingPromise: null,
builtAt: null
};
byWorkspace.set(workspaceId, next);
byWorkspace.set(key, next);
return next;
};

Expand Down Expand Up @@ -134,14 +141,14 @@ export function createFileSearchIndexService() {
});
};

const shouldSkipDirectoryName = (name: string): boolean => {
const shouldSkipDirectoryName = (name: string, includeIgnored: boolean): boolean => {
if (name === ".git") return true;
if (name === "node_modules") return true;
return false;
};

const buildWorkspace = async (index: WorkspaceIndex, opts: {
shouldIgnore: (relPath: string) => Promise<boolean>;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): Promise<void> => {
index.files.clear();
index.totalContentBytes = 0;
Expand All @@ -163,9 +170,9 @@ export function createFileSearchIndexService() {
for (const entry of entries) {
const relPath = normalizeRelative(path.join(relDir, entry.name));
if (!relPath) continue;
if (shouldSkipPathPrefix(relPath)) continue;
if (entry.isDirectory() && shouldSkipDirectoryName(entry.name)) continue;
if (await opts.shouldIgnore(relPath)) continue;
if (shouldSkipPathPrefix(relPath, index.includeIgnored)) continue;
if (entry.isDirectory() && shouldSkipDirectoryName(entry.name, index.includeIgnored)) continue;
if (await opts.shouldIgnore(relPath, index.includeIgnored)) continue;

if (entry.isDirectory()) {
stack.push(relPath);
Expand All @@ -189,9 +196,10 @@ export function createFileSearchIndexService() {
};

const ensureBuilt = async (workspaceId: string, rootPath: string, opts: {
shouldIgnore: (relPath: string) => Promise<boolean>;
includeIgnored: boolean;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): Promise<WorkspaceIndex> => {
const index = getOrCreateWorkspaceIndex(workspaceId, rootPath);
const index = getOrCreateWorkspaceIndex(workspaceId, rootPath, opts.includeIgnored);
if (index.files.size > 0 || index.builtAt) return index;
if (index.buildingPromise) {
await index.buildingPromise;
Expand All @@ -209,9 +217,11 @@ export function createFileSearchIndexService() {
async ensureIndexed(args: {
workspaceId: string;
rootPath: string;
shouldIgnore: (relPath: string) => Promise<boolean>;
includeIgnored: boolean;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): Promise<void> {
await ensureBuilt(args.workspaceId, args.rootPath, {
includeIgnored: args.includeIgnored,
shouldIgnore: args.shouldIgnore
});
},
Expand All @@ -221,9 +231,11 @@ export function createFileSearchIndexService() {
rootPath: string;
query: string;
limit: number;
shouldIgnore: (relPath: string) => Promise<boolean>;
includeIgnored: boolean;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): Promise<FilesQuickOpenItem[]> {
const index = await ensureBuilt(args.workspaceId, args.rootPath, {
includeIgnored: args.includeIgnored,
shouldIgnore: args.shouldIgnore
});

Expand All @@ -242,9 +254,11 @@ export function createFileSearchIndexService() {
rootPath: string;
query: string;
limit: number;
shouldIgnore: (relPath: string) => Promise<boolean>;
includeIgnored: boolean;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): Promise<FilesSearchTextMatch[]> {
const index = await ensureBuilt(args.workspaceId, args.rootPath, {
includeIgnored: args.includeIgnored,
shouldIgnore: args.shouldIgnore
});

Expand Down Expand Up @@ -274,35 +288,44 @@ export function createFileSearchIndexService() {
path: string;
type: "created" | "modified" | "deleted" | "renamed";
oldPath?: string;
shouldIgnore: (relPath: string) => Promise<boolean>;
shouldIgnore: (relPath: string, includeIgnored: boolean) => Promise<boolean>;
}): void {
const index = getOrCreateWorkspaceIndex(args.workspaceId, args.rootPath);
// If this workspace was never indexed yet, defer indexing until first search/quick-open query.
if (!index.builtAt && index.files.size === 0) return;
const relPath = normalizeRelative(args.path);
const matchingIndexes = Array.from(byWorkspace.values()).filter(
(index) => index.workspaceId === args.workspaceId && index.rootPath === args.rootPath
);

if (args.oldPath) {
removePath(index, args.oldPath);
}
for (const index of matchingIndexes) {
// If this workspace/mode was never indexed yet, defer indexing until first query.
if (!index.builtAt && index.files.size === 0) continue;

if (args.type === "deleted") {
removePath(index, args.path);
return;
}
if (args.oldPath) {
removePath(index, args.oldPath);
}

const relPath = normalizeRelative(args.path);
void args.shouldIgnore(relPath).then((ignored) => {
if (ignored) {
removePath(index, relPath);
return;
if (args.type === "deleted") {
removePath(index, args.path);
continue;
}
upsertFile(index, relPath);
}).catch(() => {
// ignore indexing failures
});

void args.shouldIgnore(relPath, index.includeIgnored).then((ignored) => {
if (ignored || shouldSkipPathPrefix(relPath, index.includeIgnored)) {
removePath(index, relPath);
return;
}
upsertFile(index, relPath);
}).catch(() => {
// ignore indexing failures
});
}
},

invalidateWorkspace(workspaceId: string): void {
byWorkspace.delete(workspaceId);
for (const key of byWorkspace.keys()) {
if (key.startsWith(`${workspaceId}::`)) {
byWorkspace.delete(key);
}
}
},

dispose(): void {
Expand Down
63 changes: 55 additions & 8 deletions apps/desktop/src/main/services/files/fileService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFileService } from "./fileService";

function createLaneServiceStub(rootPath: string) {
return {
resolveWorkspaceById: vi.fn(() => ({
id: "workspace-1",
laneId: "lane-1",
rootPath,
})),
getFilesWorkspaces: vi.fn(() => []),
} as any;
}

describe("fileService", () => {
afterEach(() => {
vi.restoreAllMocks();
Expand All @@ -16,14 +27,7 @@ describe("fileService", () => {
const permissionError = Object.assign(new Error("permission denied"), { code: "EACCES" as const });
const originalLstatSync = fs.lstatSync.bind(fs);

const laneService = {
resolveWorkspaceById: vi.fn(() => ({
id: "workspace-1",
laneId: "lane-1",
rootPath,
})),
getFilesWorkspaces: vi.fn(() => []),
} as any;
const laneService = createLaneServiceStub(rootPath);

const service = createFileService({ laneService });
const spy = vi.spyOn(fs, "lstatSync").mockImplementation(((filePath: fs.PathLike) => {
Expand All @@ -45,4 +49,47 @@ describe("fileService", () => {
fs.rmSync(rootPath, { recursive: true, force: true });
}
});

it("includes ignored files in quick open and search when requested", async () => {
const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-search-"));
const { execSync } = await import("node:child_process");
execSync("git init", { cwd: rootPath, stdio: "ignore" });
const laneService = createLaneServiceStub(rootPath);
const service = createFileService({ laneService });

try {
fs.mkdirSync(path.join(rootPath, ".ade", "context"), { recursive: true });
fs.mkdirSync(path.join(rootPath, "src"), { recursive: true });
fs.writeFileSync(path.join(rootPath, ".ade", "context", "PRD.ade.md"), "# PRD\nRenderer-safe content\n", "utf8");
fs.writeFileSync(path.join(rootPath, "src", "index.ts"), "export const visible = true;\n", "utf8");

const quickOpenDefault = await service.quickOpen({
workspaceId: "workspace-1",
query: "prd",
includeIgnored: false,
});
const quickOpenIgnored = await service.quickOpen({
workspaceId: "workspace-1",
query: "prd",
includeIgnored: true,
});
const searchDefault = await service.searchText({
workspaceId: "workspace-1",
query: "renderer-safe",
includeIgnored: false,
});
const searchIgnored = await service.searchText({
workspaceId: "workspace-1",
query: "renderer-safe",
includeIgnored: true,
});

expect(quickOpenDefault).toEqual([]);
expect(quickOpenIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md");
expect(searchDefault).toEqual([]);
expect(searchIgnored.map((item) => item.path)).toContain(".ade/context/PRD.ade.md");
} finally {
fs.rmSync(rootPath, { recursive: true, force: true });
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
39 changes: 25 additions & 14 deletions apps/desktop/src/main/services/files/fileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ async function runGitCheckIgnoreBatch(args: { cwd: string; paths: string[]; time
finish(ignored);
});

child.stdin.on("error", () => finish(new Set<string>()));

try {
child.stdin.write(`${args.paths.join("\n")}\n`);
child.stdin.end();
Expand Down Expand Up @@ -246,6 +248,9 @@ export function createFileService({
}
};

const shouldIgnoreForRoot = (rootPath: string) =>
(relPath: string, includeIgnored: boolean) => isIgnoredPath(rootPath, relPath, includeIgnored);

const emitLaneMutation = (workspaceId: string, reason: string) => {
if (!onLaneWorktreeMutation) return;
const workspace = resolveWorkspace(workspaceId);
Expand Down Expand Up @@ -471,7 +476,7 @@ export function createFileService({
rootPath: workspace.rootPath,
path: normalizedRel,
type: "modified",
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
emitLaneMutation(args.workspaceId, "file_write");
},
Expand All @@ -489,7 +494,7 @@ export function createFileService({
rootPath: workspace.rootPath,
path: normalizedRel,
type: "created",
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
emitLaneMutation(args.workspaceId, "file_create");
},
Expand Down Expand Up @@ -518,7 +523,7 @@ export function createFileService({
type: "renamed",
oldPath: oldRel,
path: newRel,
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
emitLaneMutation(args.workspaceId, "file_rename");
},
Expand All @@ -539,23 +544,27 @@ export function createFileService({
rootPath: workspace.rootPath,
path: normalizedRel,
type: "deleted",
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
emitLaneMutation(args.workspaceId, "file_delete");
},

async watchWorkspace(args: FilesWatchArgs, callback: (ev: FileChangeEvent) => void, senderId: number): Promise<void> {
const workspace = resolveWorkspace(args.workspaceId);
await indexService.ensureIndexed({
workspaceId: args.workspaceId,
rootPath: workspace.rootPath,
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
});
if (!args.includeIgnored) {
await indexService.ensureIndexed({
workspaceId: args.workspaceId,
rootPath: workspace.rootPath,
includeIgnored: false,
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
}
watcherService.watch(
{
workspaceId: args.workspaceId,
rootPath: workspace.rootPath,
senderId
senderId,
includeIgnored: Boolean(args.includeIgnored)
},
(ev) => {
invalidateGitStatusCache(workspace.rootPath);
Expand All @@ -568,15 +577,15 @@ export function createFileService({
type: ev.type,
path: ev.path,
oldPath: ev.oldPath,
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
callback(ev);
}
);
},

stopWatching(args: FilesWatchArgs, senderId: number): void {
watcherService.stop(args.workspaceId, senderId);
watcherService.stop(args.workspaceId, senderId, Boolean(args.includeIgnored));
},

stopWatchingBySender(senderId: number): void {
Expand All @@ -593,7 +602,8 @@ export function createFileService({
rootPath: workspace.rootPath,
query,
limit,
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
includeIgnored: Boolean(args.includeIgnored),
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
},

Expand All @@ -607,7 +617,8 @@ export function createFileService({
rootPath: workspace.rootPath,
query,
limit,
shouldIgnore: (relPath) => isIgnoredPath(workspace.rootPath, relPath, false)
includeIgnored: Boolean(args.includeIgnored),
shouldIgnore: shouldIgnoreForRoot(workspace.rootPath)
});
},

Expand Down
Loading
Loading