From 6e57bb1e4f874b5bce033537575ede4c17f49998 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 11:45:15 +0200 Subject: [PATCH] fix(app): tell the truth about search scope and file identity Two reproduced failures from the full correctness review. An empty result claimed the whole workspace had been read. Ripgrep never opens a file above the host's per-file ceiling, so a workspace whose only match lives in a large log answered "No matches" with nothing else said, and adding a small match still reported a complete result. The response now carries the ceiling the host actually applied, and the empty state names it. Only the empty state, because that is the only place the UI claims completeness; a populated list already claims nothing, and the occurrence-cap notice stays what it was. No permanent scope strip returns. A file literally named "a\b.txt" previewed itself and then opened "a/b.txt". The client rewrote every backslash to a separator on paths the host had already made canonical, so a valid Unix file name became a directory and a different file opened under a false "File changed" notice. Identity is now kept as the producer gave it: the daemon renders workspace-relative paths with its own separator turned into "/" for content search the way the file explorer already did, and the client only normalises separators for a path whose own shape is Windows, never from the client's operating system. Native source previews sized every row at a hard-coded 20px while the row is really one line at the reader's configurable code size, so scrolling to a match landed in the wrong part of the file. The height is derived from the size the view is already handed. Revealing a match thousands of lines into a long file remains broken on Android beyond this; that is recorded with device evidence rather than claimed fixed. --- .../browser/workspace-content-search.spec.ts | 16 ++++++++ .../helpers/workspace-content-search.ts | 40 ++++++++++++++++++- .../workspace-content-search/index.tsx | 26 +++++++++++- .../internal/model.ts | 11 ++++- .../workspace-file-search-model.ts | 10 ++--- .../workspace-file-search.test.ts | 10 ++--- packages/app/src/file-pane/bar.tsx | 7 +--- packages/app/src/file-pane/pane.tsx | 15 ++----- .../app/src/file-pane/source/view.native.tsx | 29 +++++++++++--- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/utils/format-file-size.ts | 6 +++ packages/app/src/utils/path.ts | 13 ++++++ .../app/src/workspace/file-open/index.test.ts | 35 +++++++++++++--- packages/app/src/workspace/file-open/index.ts | 12 ++++-- packages/protocol/src/messages.ts | 7 ++++ .../src/server/file-explorer/service.ts | 4 +- packages/server/src/server/path-utils.ts | 11 ++++- .../workspace-content-search/index.test.ts | 24 +++++++++++ .../server/workspace-content-search/index.ts | 17 ++++++-- 27 files changed, 250 insertions(+), 52 deletions(-) create mode 100644 packages/app/src/utils/format-file-size.ts diff --git a/packages/app/e2e/browser/workspace-content-search.spec.ts b/packages/app/e2e/browser/workspace-content-search.spec.ts index 9800ddd3d8..4fbe9f76e3 100644 --- a/packages/app/e2e/browser/workspace-content-search.spec.ts +++ b/packages/app/e2e/browser/workspace-content-search.spec.ts @@ -134,3 +134,19 @@ test("CR-only saved occurrences use the source editor line and exact selection", testInfo.outputPath("cr-source-open.png"), ); }); + +import { + openLiteralBackslashFile, + reportSkippedLargeFiles, +} from "../support/helpers/workspace-content-search"; + +test("an empty result says which files the host never opened", async ({ page, withWorkspace }) => { + await reportSkippedLargeFiles(page, await withWorkspace()); +}); + +test("a file name containing a backslash opens itself, not a same-named nested path", async ({ + page, + withWorkspace, +}) => { + await openLiteralBackslashFile(page, await withWorkspace()); +}); diff --git a/packages/app/e2e/support/helpers/workspace-content-search.ts b/packages/app/e2e/support/helpers/workspace-content-search.ts index 315ee60d0f..e3671bfd62 100644 --- a/packages/app/e2e/support/helpers/workspace-content-search.ts +++ b/packages/app/e2e/support/helpers/workspace-content-search.ts @@ -1,4 +1,4 @@ -import { unlink, writeFile } from "node:fs/promises"; +import { mkdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, type Page } from "@playwright/test"; import type { CreatedWorkspace } from "./with-workspace"; @@ -152,3 +152,41 @@ export async function openLiteralSourceOccurrence( if (input.cursor) await expect(page.getByLabel(input.cursor, { exact: true })).toBeVisible(); await page.screenshot({ path: screenshot }); } + +export async function reportSkippedLargeFiles(page: Page, workspace: CreatedWorkspace) { + // rg never opens a file above the host ceiling, so an unqualified "No matches" would claim the + // whole workspace was read. + await writeFile(path.join(workspace.repoPath, "large.txt"), `needle${" ".repeat(1024 * 1024)}`); + await workspace.navigateTo(); + await page.keyboard.press("Meta+Shift+F"); + const input = page.getByRole("textbox", { name: "Search saved file contents...", exact: true }); + await input.fill("needle"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel.getByText("No matches", { exact: true })).toBeVisible(); + await expect( + panel.getByText("Files over 1.0 MB were not searched", { exact: true }), + ).toBeVisible(); + // The occurrence-cap notice is a different claim and must not appear here. + await expect(panel.getByText(/Results limited/)).toHaveCount(0); +} + +export async function openLiteralBackslashFile(page: Page, workspace: CreatedWorkspace) { + // A backslash is an ordinary character in a Unix file name; "a\b.txt" is not "a/b.txt". + await writeFile(path.join(workspace.repoPath, "a\\b.txt"), "needle LITERAL FILE"); + await mkdir(path.join(workspace.repoPath, "a"), { recursive: true }); + await writeFile(path.join(workspace.repoPath, "a", "b.txt"), "THIS IS THE OTHER FILE"); + await workspace.navigateTo(); + await page.keyboard.press("Meta+Shift+F"); + await page + .getByRole("textbox", { name: "Search saved file contents...", exact: true }) + .fill("needle"); + const panel = page.getByTestId("command-center-panel"); + await expect(panel.getByRole("button", { name: /a\\b\.txt:1:1/ })).toBeVisible(); + await expect(panel.getByTestId("file-source-editor")).toContainText("LITERAL FILE"); + await page.keyboard.press("Enter"); + await expect(panel).toBeHidden(); + const source = page.getByTestId("file-source-editor"); + await expect(source).toContainText("LITERAL FILE"); + await expect(source).not.toContainText("THIS IS THE OTHER FILE"); + await expect(page.getByRole("status").filter({ hasText: "File changed" })).toHaveCount(0); +} diff --git a/packages/app/src/command-center/workspace-content-search/index.tsx b/packages/app/src/command-center/workspace-content-search/index.tsx index 4095c73491..554fb6596c 100644 --- a/packages/app/src/command-center/workspace-content-search/index.tsx +++ b/packages/app/src/command-center/workspace-content-search/index.tsx @@ -15,6 +15,7 @@ import { createWorkspaceFileTabTarget } from "@/workspace/file-open"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { WorkspaceFileSource } from "@/file-pane/source"; +import { formatFileSize } from "@/utils/format-file-size"; import { describeWorkspaceFilePath } from "../workspace-file-search-model"; import { WorkspaceContentSearchModel, @@ -293,7 +294,20 @@ function SearchState({ state, retry }: { state: ContentSearchSnapshot; retry(): ); if (state.status === "ready") - return {t("shell.commandCenter.noMatches")}; + return ( + + {t("shell.commandCenter.noMatches")} + {/* "No matches" alone claims the whole workspace was read. Files above the host's ceiling + are never opened, so the empty state has to say so where the claim is made. */} + {state.maxFileBytes ? ( + + {t("shell.commandCenter.contentSkippedLargeFiles", { + size: formatFileSize(state.maxFileBytes), + })} + + ) : null} + + ); return null; } @@ -435,13 +449,21 @@ const styles = StyleSheet.create((theme) => ({ }, muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, error: { color: theme.colors.statusDanger }, - emptyText: { + empty: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[6], + gap: theme.spacing[1], + }, + emptyText: { textAlign: "center", color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, + emptyDetail: { + textAlign: "center", + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, limit: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], diff --git a/packages/app/src/command-center/workspace-content-search/internal/model.ts b/packages/app/src/command-center/workspace-content-search/internal/model.ts index 32eeeac1dd..b77a603004 100644 --- a/packages/app/src/command-center/workspace-content-search/internal/model.ts +++ b/packages/app/src/command-center/workspace-content-search/internal/model.ts @@ -28,6 +28,8 @@ export interface ContentSearchSnapshot { message: string; matches: WorkspaceContentMatch[]; limited: boolean; + /** Per-file byte ceiling the host applied, so an empty result can say what it did not read. */ + maxFileBytes: number | null; activeIndex: number; preview: Preview; } @@ -41,6 +43,7 @@ export class WorkspaceContentSearchModel { message: "", matches: [], limited: false, + maxFileBytes: null, activeIndex: 0, preview: { status: "empty" }, }; @@ -79,6 +82,7 @@ export class WorkspaceContentSearchModel { message: "", matches: [], limited: false, + maxFileBytes: null, activeIndex: 0, preview: { status: "empty" }, }); @@ -101,7 +105,12 @@ export class WorkspaceContentSearchModel { this.update({ status: "error", message: result.message }); return; } - this.update({ status: "ready", matches: result.matches, limited: result.limited }); + this.update({ + status: "ready", + matches: result.matches, + limited: result.limited, + maxFileBytes: result.maxFileBytes ?? null, + }); this.select(0); } catch (error) { if (!this.disposed && !controller.signal.aborted) diff --git a/packages/app/src/command-center/workspace-file-search-model.ts b/packages/app/src/command-center/workspace-file-search-model.ts index 4f43feaf78..f5cb9db8a8 100644 --- a/packages/app/src/command-center/workspace-file-search-model.ts +++ b/packages/app/src/command-center/workspace-file-search-model.ts @@ -4,12 +4,12 @@ export interface WorkspaceFileSearchEntry { directory: string; } +/** Splits a workspace-relative path the host produced, where "/" is the only separator. */ export function describeWorkspaceFilePath(path: string): WorkspaceFileSearchEntry { - const normalized = path.replace(/\\/g, "/"); - const separator = normalized.lastIndexOf("/"); + const separator = path.lastIndexOf("/"); return { - path: normalized, - name: separator >= 0 ? normalized.slice(separator + 1) : normalized, - directory: separator >= 0 ? normalized.slice(0, separator) : "", + path, + name: separator >= 0 ? path.slice(separator + 1) : path, + directory: separator >= 0 ? path.slice(0, separator) : "", }; } diff --git a/packages/app/src/command-center/workspace-file-search.test.ts b/packages/app/src/command-center/workspace-file-search.test.ts index 6070ce1576..3efcb20218 100644 --- a/packages/app/src/command-center/workspace-file-search.test.ts +++ b/packages/app/src/command-center/workspace-file-search.test.ts @@ -10,11 +10,11 @@ describe("describeWorkspaceFilePath", () => { }); }); - it("normalizes Windows separators before opening or presenting a file", () => { - expect(describeWorkspaceFilePath("src\\components\\message.tsx")).toEqual({ - path: "src/components/message.tsx", - name: "message.tsx", - directory: "src/components", + it("treats a backslash as part of the file name, because the host already sent separators as /", () => { + expect(describeWorkspaceFilePath("a\\b.txt")).toEqual({ + path: "a\\b.txt", + name: "a\\b.txt", + directory: "", }); }); diff --git a/packages/app/src/file-pane/bar.tsx b/packages/app/src/file-pane/bar.tsx index 3ce1b8323f..0fb9bbefd1 100644 --- a/packages/app/src/file-pane/bar.tsx +++ b/packages/app/src/file-pane/bar.tsx @@ -7,6 +7,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import type { Theme } from "@/styles/theme"; import { FileConflictAlert, type FileConflictAlertState } from "./conflict-alert"; import type { FileEditorStatus } from "./editor/model"; +import { formatFileSize } from "@/utils/format-file-size"; const ThemedSpinner = withUnistyles(LoadingSpinner); const spinnerMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -115,12 +116,6 @@ export function FilePanelBar({ ); } -function formatFileSize(size: number): string { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - const styles = StyleSheet.create((theme) => ({ chrome: { flexShrink: 0, diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 3e88e396b8..cc6b2fac4d 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -40,6 +40,7 @@ import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; import { ZoomableImage } from "@/components/zoomable-viewport/image"; +import { formatFileSize } from "@/utils/format-file-size"; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -58,16 +59,6 @@ interface FilePreviewBodyProps { type TextExplorerFile = ExplorerFile & { kind: "text" }; -function formatFileSize({ size }: { size: number }): string { - if (size < 1024) { - return `${size} B`; - } - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB`; - } - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - function ReadonlySource({ preview, filename, @@ -95,7 +86,7 @@ function TooLargeSource({ size }: { size?: number }) { return ( {t("panels.file.tooLargeToDisplay")} - {size ? {formatFileSize({ size })} : null} + {size ? {formatFileSize(size)} : null} ); } @@ -187,7 +178,7 @@ function FilePreviewBody({ return ( {t("panels.file.binaryPreviewUnavailable")} - {formatFileSize({ size: preview.size })} + {formatFileSize(preview.size)} ); } diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx index 3ef40c93d6..85c6410f96 100644 --- a/packages/app/src/file-pane/source/view.native.tsx +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -29,6 +29,7 @@ export function FileSourceView({ location, navigationRevision, size, + theme, tooLargeMessage, }: FileSourceViewProps) { const presentation = selectSourcePresentation({ size, platform: "native" }); @@ -46,6 +47,7 @@ export function FileSourceView({ location={location} navigationRevision={navigationRevision} presentation={presentation} + codeFontSize={theme.codeFontSize} /> ); } @@ -56,8 +58,10 @@ function VirtualizedSource({ location, navigationRevision, presentation, + codeFontSize, }: Omit & { presentation: "highlighted" | "plain"; + codeFontSize: number; }) { const { t } = useTranslation(); const listRef = useRef>(null); @@ -72,6 +76,18 @@ function VirtualizedSource({ .split("\n") .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); }, [content, filename, presentation]); + // A row is exactly one line of code at the reader's own code size, so the list can jump straight + // to a match thousands of lines in. Hard-coding this height instead of deriving it left every + // reader who changed their code size looking at the wrong part of the file. + const rowHeight = sourceRowHeight(codeFontSize); + const itemLayout = useCallback( + (_data: ArrayLike | null | undefined, index: number) => ({ + length: rowHeight, + offset: index * rowHeight, + index, + }), + [rowHeight], + ); useEffect(() => { if (!location.lineStart) return; listRef.current?.scrollToIndex({ @@ -110,8 +126,8 @@ function VirtualizedSource({ keyExtractor={sourceLineKey} initialNumToRender={24} windowSize={9} - getItemLayout={sourceLineLayout} extraData={location} + getItemLayout={itemLayout} renderItem={renderLine} /> @@ -146,10 +162,13 @@ function sourceLineKey(line: SourceLine): string { return String(line.number); } -function sourceLineLayout(_data: ArrayLike | null | undefined, index: number) { - return { length: 20, offset: index * 20, index }; +/** One rendered code line; the stylesheet below builds its row from the same ratio. */ +function sourceRowHeight(codeFontSize: number): number { + return codeFontSize * SOURCE_LINE_RATIO; } +const SOURCE_LINE_RATIO = 1.45; + const styles = StyleSheet.create((theme) => ({ root: { flex: 1 }, notice: { @@ -165,7 +184,7 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[4], }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, - line: { flexDirection: "row", minHeight: theme.fontSize.code * 1.45 }, + line: { flexDirection: "row", minHeight: sourceRowHeight(theme.fontSize.code) }, gutter: { width: 56, paddingRight: theme.spacing[3], @@ -178,6 +197,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, - lineHeight: theme.fontSize.code * 1.45, + lineHeight: sourceRowHeight(theme.fontSize.code), }, })); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 7322587d03..03e3566097 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -82,6 +82,7 @@ export const ar: TranslationResources = { contentOpen: "فتح", contentLoadingPreview: "جارٍ تحميل المعاينة...", contentLimited: "النتائج محدودة — حدّد البحث أكثر", + contentSkippedLargeFiles: "لم يتم البحث في الملفات الأكبر من {{size}}", contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع", placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 519569a4a3..96ae70ec22 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -79,6 +79,7 @@ export const en = { contentOpen: "Open", contentLoadingPreview: "Loading preview...", contentLimited: "Results limited — refine your search", + contentSkippedLargeFiles: "Files over {{size}} were not searched", contentChanged: "File changed — saved match is no longer at this location", placeholder: "Search commands, files, workspaces, and agents...", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1cf69aa451..35777b21c1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -82,6 +82,7 @@ export const es: TranslationResources = { contentOpen: "Abrir", contentLoadingPreview: "Cargando vista previa...", contentLimited: "Resultados limitados — precisa la búsqueda", + contentSkippedLargeFiles: "No se buscó en archivos de más de {{size}}", contentChanged: "El archivo cambió — la coincidencia ya no está aquí", placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index dac57a6f72..8831b7e45a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -83,6 +83,7 @@ export const fr: TranslationResources = { contentOpen: "Ouvrir", contentLoadingPreview: "Chargement de l’aperçu...", contentLimited: "Résultats limités — précisez la recherche", + contentSkippedLargeFiles: "Les fichiers de plus de {{size}} n’ont pas été analysés", contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement", placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index a8649e4ad0..47edc92a8e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -82,6 +82,7 @@ export const ja: TranslationResources = { contentOpen: "開く", contentLoadingPreview: "プレビューを読み込み中...", contentLimited: "結果が上限に達しました — 検索を絞り込んでください", + contentSkippedLargeFiles: "{{size}} を超えるファイルは検索されていません", contentChanged: "ファイルが変更され、この位置に一致する内容はありません", placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 961af1776b..b49fae87a9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -82,6 +82,7 @@ export const ko: TranslationResources = { contentOpen: "열기", contentLoadingPreview: "미리보기 로딩 중...", contentLimited: "결과 제한 — 검색 범위를 좁히세요", + contentSkippedLargeFiles: "{{size}}보다 큰 파일은 검색하지 않았습니다", contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다", placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 56f65068ac..da63c12d3f 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -82,6 +82,7 @@ export const ptBR: TranslationResources = { contentOpen: "Abrir", contentLoadingPreview: "Carregando prévia...", contentLimited: "Resultados limitados — refine a busca", + contentSkippedLargeFiles: "Arquivos maiores que {{size}} não foram pesquisados", contentChanged: "Arquivo alterado — a correspondência não está mais neste local", placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 12d2de8222..7d68748480 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -82,6 +82,7 @@ export const ru: TranslationResources = { contentOpen: "Открыть", contentLoadingPreview: "Загрузка предпросмотра...", contentLimited: "Результаты ограничены — уточните поиск", + contentSkippedLargeFiles: "Файлы больше {{size}} не искались", contentChanged: "Файл изменён — совпадения больше нет в этом месте", placeholder: "Поиск команд, файлов, рабочих пространств и агентов...", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 88757b2587..a1c87cd87f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -82,6 +82,7 @@ export const zhCN: TranslationResources = { contentOpen: "打开", contentLoadingPreview: "正在加载预览...", contentLimited: "结果已达上限 — 请缩小搜索范围", + contentSkippedLargeFiles: "未搜索大于 {{size}} 的文件", contentChanged: "文件已更改 — 此位置不再匹配", placeholder: "搜索命令、文件、工作区和 Agent...", diff --git a/packages/app/src/utils/format-file-size.ts b/packages/app/src/utils/format-file-size.ts new file mode 100644 index 0000000000..632f0d954a --- /dev/null +++ b/packages/app/src/utils/format-file-size.ts @@ -0,0 +1,6 @@ +/** Renders a byte count in the units the file pane and file search already show. */ +export function formatFileSize(size: number): string { + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/packages/app/src/utils/path.ts b/packages/app/src/utils/path.ts index 6a762b0b5a..826ed6f91f 100644 --- a/packages/app/src/utils/path.ts +++ b/packages/app/src/utils/path.ts @@ -1,3 +1,16 @@ export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value); } + +/** + * Rewrites separators only for a path that is Windows-shaped, meaning it carries a drive letter + * or a UNC prefix. Everywhere else a backslash is an ordinary file name character, so a POSIX or + * workspace-relative path is returned untouched. + */ +export function normalizePathSeparators(value: string): string { + return isWindowsShapedPath(value) ? value.replace(/\\/g, "/") : value; +} + +function isWindowsShapedPath(value: string): boolean { + return value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value); +} diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index 2c75963a40..10190a3461 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -8,10 +8,10 @@ import { } from "."; describe("normalizeWorkspaceFileLocation", () => { - it("normalizes paths and valid line ranges", () => { + it("clamps line ranges and leaves the path as the producer gave it", () => { expect( normalizeWorkspaceFileLocation({ - path: "src\\app.ts", + path: "src/app.ts", lineStart: 12.8, lineEnd: 20.2, }), @@ -98,13 +98,33 @@ describe("resolveWorkspaceFilePaths", () => { }); }); - it("normalizes Windows separators in the file path", () => { + it("normalizes separators in a Windows-shaped path and leaves every other path literal", () => { expect( - resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }), + resolveWorkspaceFilePaths({ + path: "C:\\Users\\me\\repo\\src\\app.ts", + workspaceRoot: "C:\\Users\\me\\repo", + }), ).toEqual({ - absolutePath: "/Users/me/repo/src/app.ts", + absolutePath: "C:/Users/me/repo/src/app.ts", relativePath: "src/app.ts", }); + // A workspace-relative path is the host's own identity, so its backslash is a file name + // character. Rewriting it here is what opened a different file than the search result. + expect( + resolveWorkspaceFilePaths({ path: "a\\b.txt", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/a\\b.txt", + relativePath: "a\\b.txt", + }); + expect( + resolveWorkspaceFilePaths({ + path: "/Users/me/repo/a\\b.txt", + workspaceRoot: "/Users/me/repo", + }), + ).toEqual({ + absolutePath: "/Users/me/repo/a\\b.txt", + relativePath: "a\\b.txt", + }); }); it("normalizes dot segments in workspace-relative paths", () => { @@ -226,3 +246,8 @@ it("preserves literal filename and workspace whitespace through location and hos relativePath: " leading.txt ", }); }); + +it("preserves a literal backslash file name through location identity", () => { + const location = { path: "a\\b.txt", lineStart: 1, columnStart: 1, expectedText: "needle" }; + expect(normalizeWorkspaceFileLocation(location)).toEqual(location); +}); diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index a777871638..f31cc19a22 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -1,4 +1,4 @@ -import { isAbsolutePath } from "@/utils/path"; +import { isAbsolutePath, normalizePathSeparators } from "@/utils/path"; export type OpenFileDisposition = "main" | "preferred" | "side"; @@ -29,7 +29,11 @@ export function normalizeWorkspaceFileLocation( return null; } - const path = location.path.replace(/\\/g, "/"); + // The path is an identity the host already produced, so it is kept verbatim: a backslash is a + // file name character outside Windows, and rewriting it here opened a different file than the + // one a search result or explorer row named. Callers holding a path scraped out of text + // normalize it themselves before arriving. + const path = location.path; if (!path) { return null; } @@ -162,8 +166,8 @@ export function resolveWorkspaceFilePaths(input: { path: string; workspaceRoot: string; }): ResolvedWorkspaceFilePaths | null { - const filePath = input.path.replace(/\\/g, "/"); - const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.replace(/\\/g, "/")); + const filePath = normalizePathSeparators(input.path); + const workspaceRoot = normalizeAbsolutePath(normalizePathSeparators(input.workspaceRoot)); if (!filePath || !workspaceRoot) { return null; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7afcffd563..54e72e1f3a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2703,6 +2703,13 @@ export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", status: z.literal("ok"), matches: z.array(WorkspaceContentMatchSchema), limited: z.boolean(), + /** + * Per-file byte ceiling the host applied. Files above it are never opened, so a result set + * is complete only for files at or under this size. Absent on hosts that do not report it. + */ + // COMPAT(workspaceContentSearchMaxFileBytes): added in v0.8.0, remove optionality after + // 2027-03-10 once the daemon floor reports it. + maxFileBytes: z.number().int().positive().optional(), }), z.object({ status: z.literal("error"), diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 8e4b93a060..fb8ffe612d 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -2,7 +2,7 @@ import { constants, promises as fs, type BigIntStats, type Stats } from "fs"; import type { FileHandle } from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; -import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; +import { expandUserPath, resolvePathFromBase, toWorkspaceRelativePath } from "../path-utils.js"; import { runGitCommand } from "../../utils/run-git-command.js"; export type ExplorerEntryKind = "file" | "directory"; @@ -871,7 +871,7 @@ function normalizeRelativePath({ root, targetPath }: { root: string; targetPath: const normalizedRoot = expandUserPath(root); const normalizedTarget = expandUserPath(targetPath); const relative = path.relative(normalizedRoot, normalizedTarget); - return relative === "" ? "." : relative.split(path.sep).join("/"); + return relative === "" ? "." : toWorkspaceRelativePath(relative); } function textMimeTypeForExtension(ext: string): string { diff --git a/packages/server/src/server/path-utils.ts b/packages/server/src/server/path-utils.ts index bb3c12436e..76064fee33 100644 --- a/packages/server/src/server/path-utils.ts +++ b/packages/server/src/server/path-utils.ts @@ -1,5 +1,5 @@ import { homedir } from "node:os"; -import { isAbsolute, posix, resolve, win32 } from "node:path"; +import { isAbsolute, posix, resolve, sep, win32 } from "node:path"; export function assertAbsolutePath(cwd: string): void { if (!posix.isAbsolute(cwd) && !win32.isAbsolute(cwd)) { @@ -38,3 +38,12 @@ export function isSameOrDescendantPath(basePath: string, candidatePath: string): normalizedCandidate === normalizedBase || normalizedCandidate.startsWith(normalizedBase + "/") ); } + +/** + * Renders a host-relative path the way every workspace-relative identity crosses the wire: this + * host's separator becomes "/", and every other character survives, so a Unix file name that + * contains a backslash stays that file rather than becoming a directory on the client. + */ +export function toWorkspaceRelativePath(relative: string): string { + return relative.split(sep).join("/"); +} diff --git a/packages/server/src/server/workspace-content-search/index.test.ts b/packages/server/src/server/workspace-content-search/index.test.ts index 2837435efd..eadd61c1c9 100644 --- a/packages/server/src/server/workspace-content-search/index.test.ts +++ b/packages/server/src/server/workspace-content-search/index.test.ts @@ -27,6 +27,7 @@ test("searches saved untracked text literally, ignoring case and ignore rules in expect(result).toEqual({ status: "ok", limited: false, + maxFileBytes: 1_048_576, matches: [ { path: ".hidden", @@ -75,6 +76,7 @@ test("reports missing rg, empty search, cancellation and file/result limits", as status: "ok", matches: [], limited: false, + maxFileBytes: 1_048_576, }); expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({ status: "error", @@ -155,3 +157,25 @@ test("validates UTF-8 beyond the matching record and preserves an embedded BOM c embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]), ).toEqual([["\uFEFFneedle", 8]]); }); + +test("states the per-file ceiling so a search that skipped an oversized file is not reported as complete", async () => { + const cwd = await workspace({ "large.txt": `needle${" ".repeat(1_048_576)}` }); + expect(await searchWorkspaceContent({ cwd, query: "needle" })).toEqual({ + status: "ok", + matches: [], + limited: false, + maxFileBytes: 1_048_576, + }); + await writeFile(path.join(cwd, "small.txt"), "needle"); + const withSmall = await searchWorkspaceContent({ cwd, query: "needle" }); + expect( + withSmall.status === "ok" && [withSmall.matches.map((m) => m.path), withSmall.maxFileBytes], + ).toEqual([["small.txt"], 1_048_576]); +}); + +test("keeps a literal backslash in a saved file name exactly as the host reported it", async () => { + // A backslash is an ordinary character in a Unix file name, never a separator. + const cwd = await workspace({ "a\\b.txt": "needle" }); + const result = await searchWorkspaceContent({ cwd, query: "needle" }); + expect(result.status === "ok" && result.matches.map((match) => match.path)).toEqual(["a\\b.txt"]); +}); diff --git a/packages/server/src/server/workspace-content-search/index.ts b/packages/server/src/server/workspace-content-search/index.ts index 934f43133d..f02c1c106e 100644 --- a/packages/server/src/server/workspace-content-search/index.ts +++ b/packages/server/src/server/workspace-content-search/index.ts @@ -1,4 +1,5 @@ import { stat } from "node:fs/promises"; +import { sep } from "node:path"; import type { ChildProcess } from "node:child_process"; import type { WorkspaceContentMatch, @@ -6,6 +7,7 @@ import type { } from "@getpaseo/protocol/messages"; import { readExplorerFileBytes } from "../file-explorer/service.js"; import { spawnProcess } from "../../utils/spawn.js"; +import { toWorkspaceRelativePath } from "../path-utils.js"; const MAX_RESULTS = 200; const MAX_FILE_BYTES = 1_048_576; @@ -23,7 +25,8 @@ export async function searchWorkspaceContent( ) { return failure("invalid_query", "Search text must be one line, up to 1,024 bytes"); } - if (!input.query) return { status: "ok", matches: [], limited: false }; + if (!input.query) + return { status: "ok", matches: [], limited: false, maxFileBytes: MAX_FILE_BYTES }; try { if (!(await stat(input.cwd)).isDirectory()) return failure("unavailable", "Workspace directory is unavailable"); @@ -115,7 +118,7 @@ export async function searchWorkspaceContent( const event = JSON.parse(record.toString("utf8")) as RgEvent; if (event.type === "begin") { pending = []; - pendingPath = event.data.path?.text?.replace(/^\.\//, "") ?? ""; + pendingPath = workspacePathFromRg(event.data.path?.text); } else if (event.type === "match" && pendingPath) { pending.push( ...readMatches( @@ -158,11 +161,19 @@ export async function searchWorkspaceContent( error ??= failure("unavailable", "Files changed while searching — retry"); } cleanup(); - resolve(error ?? { status: "ok", matches: located, limited }); + resolve(error ?? { status: "ok", matches: located, limited, maxFileBytes: MAX_FILE_BYTES }); }); }); } +/** rg reports paths under the "." root using this host's separator; identities cross the wire + * with "/" so a client never has to guess which character was a separator. */ +function workspacePathFromRg(reported: string | undefined): string { + if (!reported) return ""; + const relative = reported.startsWith(`.${sep}`) ? reported.slice(2) : reported; + return toWorkspaceRelativePath(relative); +} + interface RgEvent { type: string; data: {