History

Add VS Code file icons and tighten workspace entry filtering

- render theme-aware VS Code icons for path suggestions in chat composer - exclude `.convex` and gitignored paths more reliably in workspace indexing - add tests plus a sync script and icon manifest/association data

Julius Marminge committed 8 months ago

Commit f04b2d91 parent 3109d1a9 files · +558 −51Browse files

Showing the changes to apps/server/src (2 of 9 files). Show all

Unified · Split

2 files
  1. apps/server/src/workspaceEntries.test.ts +47 −16
  2. apps/server/src/workspaceEntries.ts +74 −12
apps/server/src/workspaceEntries.test.ts+47−16
@@ -3,7 +3,7 @@ import os from "node:os"; · 2 unmodified lines
3import path from "node:path";3import path from "node:path";
4import { spawnSync } from "node:child_process";4import { spawnSync } from "node:child_process";
5 5
6import { afterEach, describe, expect, it } from "vitest";6import { afterEach, assert, describe, it } from "vitest";
7 7
8import { searchWorkspaceEntries } from "./workspaceEntries";8import { searchWorkspaceEntries } from "./workspaceEntries";
9 9
@@ -46,13 +46,13 @@ describe("searchWorkspaceEntries", () => { · 36 unmodified lines
46 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });46 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
47 const paths = result.entries.map((entry) => entry.path);47 const paths = result.entries.map((entry) => entry.path);
48 48
49 expect(paths).toContain("src");49 assert.include(paths, "src");
50 expect(paths).toContain("src/components");50 assert.include(paths, "src/components");
51 expect(paths).toContain("src/components/Composer.tsx");51 assert.include(paths, "src/components/Composer.tsx");
52 expect(paths).toContain("README.md");52 assert.include(paths, "README.md");
53 expect(paths.some((entryPath) => entryPath.startsWith(".git"))).toBe(false);53 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git")));
54 expect(paths.some((entryPath) => entryPath.startsWith("node_modules"))).toBe(false);54 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules")));
55 expect(result.truncated).toBe(false);55 assert.isFalse(result.truncated);
56 });56 });
57 57
58 it("filters and ranks entries by query", async () => {58 it("filters and ranks entries by query", async () => {
@@ -63,25 +63,56 @@ describe("searchWorkspaceEntries", () => { · 4 unmodified lines
63 63
64 const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });64 const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 });
65 65
66 expect(result.entries.length).toBeGreaterThan(0);66 assert.isAbove(result.entries.length, 0);
67 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);67 assert.isTrue(result.entries.some((entry) => entry.path === "src/components"));
68 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true);68 assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo")));
69 });69 });
70 70
71 it("excludes gitignored paths for git repositories", async () => {71 it("excludes gitignored paths for git repositories", async () => {
72 const cwd = makeTempDir("t3code-workspace-gitignore-");72 const cwd = makeTempDir("t3code-workspace-gitignore-");
73 runGit(cwd, ["init"]);73 runGit(cwd, ["init"]);
74 writeFile(cwd, ".gitignore", ".convex/\nignored.txt\n");74 writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n");
75 writeFile(cwd, "src/keep.ts", "export {};");75 writeFile(cwd, "src/keep.ts", "export {};");
76 writeFile(cwd, "ignored.txt", "ignore me");76 writeFile(cwd, "ignored.txt", "ignore me");
77 writeFile(cwd, ".convex/local-storage/data.json", "{}");77 writeFile(cwd, ".convex/local-storage/data.json", "{}");
78 writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}");
78 79
79 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });80 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
80 const paths = result.entries.map((entry) => entry.path);81 const paths = result.entries.map((entry) => entry.path);
81 82
82 expect(paths).toContain("src");83 assert.include(paths, "src");
83 expect(paths).toContain("src/keep.ts");84 assert.include(paths, "src/keep.ts");
84 expect(paths).not.toContain("ignored.txt");85 assert.notInclude(paths, "ignored.txt");
85 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);86 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
87 assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/")));
88 });
89
90 it("excludes tracked paths that match ignore rules", async () => {
91 const cwd = makeTempDir("t3code-workspace-tracked-gitignore-");
92 runGit(cwd, ["init"]);
93 writeFile(cwd, ".convex/local-storage/data.json", "{}");
94 writeFile(cwd, "src/keep.ts", "export {};");
95 runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]);
96 writeFile(cwd, ".gitignore", ".convex/\n");
97
98 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
99 const paths = result.entries.map((entry) => entry.path);
100
101 assert.include(paths, "src");
102 assert.include(paths, "src/keep.ts");
103 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
104 });
105
106 it("excludes .convex in non-git workspaces", async () => {
107 const cwd = makeTempDir("t3code-workspace-non-git-convex-");
108 writeFile(cwd, ".convex/local-storage/data.json", "{}");
109 writeFile(cwd, "src/keep.ts", "export {};");
110
111 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
112 const paths = result.entries.map((entry) => entry.path);
113
114 assert.include(paths, "src");
115 assert.include(paths, "src/keep.ts");
116 assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/")));
86 });117 });
87});118});
apps/server/src/workspaceEntries.ts+74−12
@@ -1,4 +1,5 @@
1import fs from "node:fs/promises";1import fs from "node:fs/promises";
2import type { Dirent } from "node:fs";
2import path from "node:path";3import path from "node:path";
3import { runProcess } from "./processRunner";4import { runProcess } from "./processRunner";
4 5
@@ -14,6 +15,7 @@ const WORKSPACE_CACHE_MAX_KEYS = 4; · 9 unmodified lines
14const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;15const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
15const IGNORED_DIRECTORY_NAMES = new Set([16const IGNORED_DIRECTORY_NAMES = new Set([
16 ".git",17 ".git",
18 ".convex",
17 "node_modules",19 "node_modules",
18 ".next",20 ".next",
19 ".turbo",21 ".turbo",
@@ -99,14 +101,52 @@ function directoryAncestorsOf(relativePath: string): string[] { · 79 unmodified lines
99 return directories;101 return directories;
100}102}
101 103
102async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {104async function isInsideGitWorkTree(cwd: string): Promise<boolean> {
103 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {105 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
104 cwd,106 cwd,
105 allowNonZeroExit: true,107 allowNonZeroExit: true,
106 timeoutMs: 5_000,108 timeoutMs: 5_000,
107 maxBufferBytes: 4_096,109 maxBufferBytes: 4_096,
108 }).catch(() => null);110 }).catch(() => null);
109 if (!insideWorkTree || insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== "true") {111 return Boolean(
112 insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true",
113 );
114}
115
116async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise<string[]> {
117 if (relativePaths.length === 0) {
118 return relativePaths;
119 }
120
121 const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], {
122 cwd,
123 allowNonZeroExit: true,
124 timeoutMs: 20_000,
125 maxBufferBytes: 16 * 1024 * 1024,
126 outputMode: "truncate",
127 stdin: `${relativePaths.join("\0")}\0`,
128 }).catch(() => null);
129 if (!checkIgnore) {
130 return relativePaths;
131 }
132
133 // git-check-ignore exits with 1 when no paths match.
134 if (checkIgnore.code !== 0 && checkIgnore.code !== 1) {
135 return relativePaths;
136 }
137
138 const ignoredPaths = new Set(
139 splitNullSeparatedPaths(checkIgnore.stdout, Boolean(checkIgnore.stdoutTruncated)),
140 );
141 if (ignoredPaths.size === 0) {
142 return relativePaths;
143 }
144
145 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
146}
147
148async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {
149 if (!(await isInsideGitWorkTree(cwd))) {
110 return null;150 return null;
111 }151 }
112 152
@@ -125,9 +165,13 @@ async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | · 12 unmodified lines
125 return null;165 return null;
126 }166 }
127 167
128 const filePaths = splitNullSeparatedPaths(listedFiles.stdout, Boolean(listedFiles.stdoutTruncated))168 const listedPaths = splitNullSeparatedPaths(
169 listedFiles.stdout,
170 Boolean(listedFiles.stdoutTruncated),
171 )
129 .map((entry) => toPosixPath(entry))172 .map((entry) => toPosixPath(entry))
130 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));173 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
174 const filePaths = await filterGitIgnoredPaths(cwd, listedPaths);
131 175
132 const directorySet = new Set<string>();176 const directorySet = new Set<string>();
133 for (const filePath of filePaths) {177 for (const filePath of filePaths) {
@@ -167,6 +211,7 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 33 unmodified lines
167 if (gitIndexed) {211 if (gitIndexed) {
168 return gitIndexed;212 return gitIndexed;
169 }213 }
214 const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd);
170 215
171 let pendingDirectories: string[] = [""];216 let pendingDirectories: string[] = [""];
172 const entries: ProjectEntry[] = [];217 const entries: ProjectEntry[] = [];
@@ -193,13 +238,12 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 20 unmodified lines
193 }),238 }),
194 );239 );
195 240
196 for (const directoryEntry of directoryEntries) {241 const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => {
197 const { relativeDir, dirents } = directoryEntry;242 const { relativeDir, dirents } = directoryEntry;
198 if (!dirents) {243 if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>;
199 continue;
200 }
201 244
202 dirents.sort((left, right) => left.name.localeCompare(right.name));245 dirents.sort((left, right) => left.name.localeCompare(right.name));
246 const candidates: Array<{ dirent: Dirent; relativePath: string }> = [];
203 for (const dirent of dirents) {247 for (const dirent of dirents) {
204 if (!dirent.name || dirent.name === "." || dirent.name === "..") {248 if (!dirent.name || dirent.name === "." || dirent.name === "..") {
205 continue;249 continue;
@@ -217,15 +261,33 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> { · 11 unmodified lines
217 if (isPathInIgnoredDirectory(relativePath)) {261 if (isPathInIgnoredDirectory(relativePath)) {
218 continue;262 continue;
219 }263 }
264 candidates.push({ dirent, relativePath });
265 }
266 return candidates;
267 });
268
269 const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) =>
270 candidateEntries.map((entry) => entry.relativePath),
271 );
272 const allowedPathSet = shouldFilterWithGitIgnore
273 ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths))
274 : null;
275
276 for (const candidateEntries of candidateEntriesByDirectory) {
277 for (const candidate of candidateEntries) {
278 if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) {
279 continue;
280 }
281
220 const entry: ProjectEntry = {282 const entry: ProjectEntry = {
221 path: relativePath,283 path: candidate.relativePath,
222 kind: dirent.isDirectory() ? "directory" : "file",284 kind: candidate.dirent.isDirectory() ? "directory" : "file",
223 parentPath: parentPathOf(relativePath),285 parentPath: parentPathOf(candidate.relativePath),
224 };286 };
225 entries.push(entry);287 entries.push(entry);
226 288
227 if (dirent.isDirectory()) {289 if (candidate.dirent.isDirectory()) {
228 pendingDirectories.push(relativePath);290 pendingDirectories.push(candidate.relativePath);
229 }291 }
230 292
231 if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {293 if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) {