From cd63389152190ebcb72f06e2d187754661406ce8 Mon Sep 17 00:00:00 2001 From: Ralph Smith Date: Mon, 17 Aug 2026 20:39:03 -0600 Subject: [PATCH 1/3] fix(server): keep normalized workspace paths unique --- .../workspace/WorkspaceSearchIndex.test.ts | 34 +++++++++++++++++++ .../src/workspace/WorkspaceSearchIndex.ts | 5 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 1fdf956447d4..a51141b99628 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -4,6 +4,7 @@ import { type GrepCursor, type GrepOptions, type GrepResult, + type MixedItem, } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -30,6 +31,39 @@ function fileItem(relativePath: string): FileItem { }; } +function mixedFileItem(relativePath: string): MixedItem { + return { type: "file", item: fileItem(relativePath) }; +} + +it.effect("deduplicates paths using their wire-normalized value", () => + Effect.scoped( + Effect.gen(function* () { + const items = [mixedFileItem("pkg/notes.txt"), mixedFileItem("pkg/notes.txt\n")]; + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + mixedSearch: vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: items.length, + }, + })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const result = yield* searchIndex.list(); + + expect(result.entries.filter((entry) => entry.path === "pkg/notes.txt")).toEqual([ + { kind: "file", path: "pkg/notes.txt" }, + ]); + }), + ), +); + it.effect("filters image searches before applying the result limit", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index eeb2df342c2c..cc24f0afec37 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -138,7 +138,10 @@ function parentPathOf(input: string): string | undefined { } function toProjectEntry(item: MixedItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)); + // ProjectEntry.path trims at the wire boundary. Normalize before the path + // map deduplicates entries so distinct raw names cannot collapse into a + // duplicate that crashes the client file tree. + const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)).trim(); if (!normalizedPath) { return null; } From 696eb7c084b9bd5ead441beefdfa49b0e123c05f Mon Sep 17 00:00:00 2001 From: Ralph Smith Date: Mon, 17 Aug 2026 22:18:09 -0600 Subject: [PATCH 2/3] fix(server): deduplicate normalized search results --- .../workspace/WorkspaceSearchIndex.test.ts | 43 +++++++++++++- .../src/workspace/WorkspaceSearchIndex.ts | 56 ++++++++++--------- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index a51141b99628..72486ab226a0 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,5 +1,6 @@ import { FileFinder, + type DirItem, type FileItem, type GrepCursor, type GrepOptions, @@ -35,6 +36,15 @@ function mixedFileItem(relativePath: string): MixedItem { return { type: "file", item: fileItem(relativePath) }; } +function mixedDirectoryItem(relativePath: string): MixedItem { + const item: DirItem = { + relativePath, + dirName: relativePath.slice(relativePath.lastIndexOf("/") + 1), + maxAccessFrecency: 0, + }; + return { type: "directory", item }; +} + it.effect("deduplicates paths using their wire-normalized value", () => Effect.scoped( Effect.gen(function* () { @@ -55,11 +65,40 @@ it.effect("deduplicates paths using their wire-normalized value", () => vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); - const result = yield* searchIndex.list(); + const listResult = yield* searchIndex.list(); + const searchResult = yield* searchIndex.search("", 10); - expect(result.entries.filter((entry) => entry.path === "pkg/notes.txt")).toEqual([ + expect(listResult.entries.filter((entry) => entry.path === "pkg/notes.txt")).toEqual([ { kind: "file", path: "pkg/notes.txt" }, ]); + expect(searchResult.entries).toEqual([{ kind: "file", path: "pkg/notes.txt" }]); + }), + ), +); + +it.effect("trims wire whitespace before removing directory separators", () => + Effect.scoped( + Effect.gen(function* () { + const items = [mixedDirectoryItem("pkg/"), mixedFileItem("pkg/ ")]; + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + mixedSearch: vi.fn(() => ({ + ok: true as const, + value: { + items, + scores: [], + totalMatched: items.length, + totalFiles: 1, + }, + })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); + const result = yield* searchIndex.search("", 10); + + expect(result.entries).toEqual([{ kind: "directory", path: "pkg" }]); }), ), ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index cc24f0afec37..ca67abef8992 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -132,16 +132,19 @@ function trimDirectorySeparator(input: string): string { return input.endsWith("/") ? input.slice(0, -1) : input; } +function normalizeProjectEntryPath(input: string): string { + // ProjectEntry.path trims at the wire boundary. Trim first so a legal + // whitespace-only final segment cannot leave a separator-only collision. + return trimDirectorySeparator(toPosixPath(input).trim()); +} + function parentPathOf(input: string): string | undefined { const separatorIndex = input.lastIndexOf("/"); return separatorIndex === -1 ? undefined : input.slice(0, separatorIndex); } function toProjectEntry(item: MixedItem): ProjectEntry | null { - // ProjectEntry.path trims at the wire boundary. Normalize before the path - // map deduplicates entries so distinct raw names cannot collapse into a - // duplicate that crashes the client file tree. - const normalizedPath = trimDirectorySeparator(toPosixPath(item.item.relativePath)).trim(); + const normalizedPath = normalizeProjectEntryPath(item.item.relativePath); if (!normalizedPath) { return null; } @@ -153,24 +156,37 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { } function toFileEntry(item: FileItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + const normalizedPath = normalizeProjectEntryPath(item.relativePath); return normalizedPath ? { path: normalizedPath, kind: "file" } : null; } function toDirectoryEntry(item: DirItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + const normalizedPath = normalizeProjectEntryPath(item.relativePath); return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; } +function uniqueProjectEntries(entries: Iterable): ProjectEntry[] { + const uniqueEntries: ProjectEntry[] = []; + const paths = new Set(); + for (const entry of entries) { + if (entry === null || paths.has(entry.path)) continue; + paths.add(entry.path); + uniqueEntries.push(entry); + } + return uniqueEntries; +} + function mapFileSearchResult( result: SearchResult, limit: number, imageOnly = false, ): ProjectSearchEntriesResult { - const entries = result.items.flatMap((item) => { - const entry = toFileEntry(item); - return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; - }); + const entries = uniqueProjectEntries( + result.items.map((item) => { + const entry = toFileEntry(item); + return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? entry : null; + }), + ); return { entries: entries.slice(0, limit), truncated: entries.length > limit || result.totalMatched > result.items.length, @@ -181,10 +197,7 @@ function mapDirectorySearchResult( result: DirSearchResult, limit: number, ): ProjectSearchEntriesResult { - const entries = result.items.flatMap((item) => { - const entry = toDirectoryEntry(item); - return entry ? [entry] : []; - }); + const entries = uniqueProjectEntries(result.items.map(toDirectoryEntry)); const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; return { entries: entries.slice(0, limit), @@ -196,16 +209,7 @@ function mapMixedSearchResult( result: MixedSearchResult, limit: number, ): { readonly entries: ProjectEntry[]; readonly truncated: boolean } { - const entries: ProjectEntry[] = []; - for (const item of result.items) { - const entry = toProjectEntry(item); - if (entry) { - entries.push(entry); - } - if (entries.length >= limit) { - break; - } - } + const entries = uniqueProjectEntries(result.items.map(toProjectEntry)); const rootDirectoryCount = result.items.some( (item) => item.type === "directory" && item.item.relativePath.length === 0, @@ -213,8 +217,8 @@ function mapMixedSearchResult( ? 1 : 0; return { - entries, - truncated: result.totalMatched - rootDirectoryCount > limit, + entries: entries.slice(0, limit), + truncated: entries.length > limit || result.totalMatched - rootDirectoryCount > limit, }; } From 378f54f5b66c0733fd782aec0a03c1e8a4eddf4c Mon Sep 17 00:00:00 2001 From: Ralph Smith Date: Mon, 17 Aug 2026 22:24:53 -0600 Subject: [PATCH 3/3] fix(server): prefer directories on path collisions --- .../src/workspace/WorkspaceSearchIndex.test.ts | 4 +++- apps/server/src/workspace/WorkspaceSearchIndex.ts | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 72486ab226a0..7a67809a3685 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -79,7 +79,9 @@ it.effect("deduplicates paths using their wire-normalized value", () => it.effect("trims wire whitespace before removing directory separators", () => Effect.scoped( Effect.gen(function* () { - const items = [mixedDirectoryItem("pkg/"), mixedFileItem("pkg/ ")]; + // The file ranks first to prove that a later directory upgrades the + // collision instead of leaving the file kind attached to a real folder. + const items = [mixedFileItem("pkg/ "), mixedDirectoryItem("pkg/")]; const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index ca67abef8992..c18a13a605bc 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -167,11 +167,18 @@ function toDirectoryEntry(item: DirItem): ProjectEntry | null { function uniqueProjectEntries(entries: Iterable): ProjectEntry[] { const uniqueEntries: ProjectEntry[] = []; - const paths = new Set(); + const indexByPath = new Map(); for (const entry of entries) { - if (entry === null || paths.has(entry.path)) continue; - paths.add(entry.path); - uniqueEntries.push(entry); + if (entry === null) continue; + const existingIndex = indexByPath.get(entry.path); + if (existingIndex === undefined) { + indexByPath.set(entry.path, uniqueEntries.length); + uniqueEntries.push(entry); + continue; + } + if (entry.kind === "directory" && uniqueEntries[existingIndex]?.kind === "file") { + uniqueEntries[existingIndex] = entry; + } } return uniqueEntries; }