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: {