- 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
Showing the changes to apps/server/src (2 of 9 files). Show all
Unified · Split
| @@ -3,7 +3,7 @@ import os from "node:os"; · 2 unmodified lines | |||
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | import { spawnSync } from "node:child_process"; | 4 | import { spawnSync } from "node:child_process"; |
| 5 | 5 | ||
| 6 | import { afterEach, describe, expect, it } from "vitest"; | 6 | import { afterEach, assert, describe, it } from "vitest"; |
| 7 | 7 | ||
| 8 | import { searchWorkspaceEntries } from "./workspaceEntries"; | 8 | import { 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 | }); |
| @@ -1,4 +1,5 @@ | |||
| 1 | import fs from "node:fs/promises"; | 1 | import fs from "node:fs/promises"; |
| 2 | import type { Dirent } from "node:fs"; | ||
| 2 | import path from "node:path"; | 3 | import path from "node:path"; |
| 3 | import { runProcess } from "./processRunner"; | 4 | import { runProcess } from "./processRunner"; |
| 4 | 5 | ||
| @@ -14,6 +15,7 @@ const WORKSPACE_CACHE_MAX_KEYS = 4; · 9 unmodified lines | |||
| 14 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; | 15 | const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; |
| 15 | const IGNORED_DIRECTORY_NAMES = new Set([ | 16 | const 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 | ||
| 102 | async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> { | 104 | async 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 | |||
| 116 | async 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 | |||
| 148 | async 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) { |