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