();
+// The Find widget floats inside this frame, so it stays relative; the change notice is the
+// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
+ flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx
index a5630ed82f..557482d868 100644
--- a/packages/app/src/file-pane/pane.tsx
+++ b/packages/app/src/file-pane/pane.tsx
@@ -58,14 +58,6 @@ interface FilePreviewBodyProps {
type TextExplorerFile = ExplorerFile & { kind: "text" };
-function trimNonEmpty(value: string | null | undefined): string | null {
- if (typeof value !== "string") {
- return null;
- }
- const trimmed = value.trim();
- return trimmed.length > 0 ? trimmed : null;
-}
-
function formatFileSize({ size }: { size: number }): string {
if (size < 1024) {
return `${size} B`;
@@ -164,7 +156,7 @@ function FilePreviewBody({
);
}
- if (preview.kind === "text") {
+ if (preview.kind === "text" || (location.lineStart && preview.content !== undefined)) {
if (renderKind === "html") {
// The HTML document owns its own scrolling, so no ScrollView wrapper here.
return (
@@ -239,8 +231,8 @@ export function FilePane({
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
- const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
- const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
+ const normalizedWorkspaceRoot = workspaceRoot;
+ const normalizedFilePath = location.path || null;
const readTarget = useMemo(
() =>
normalizedFilePath
diff --git a/packages/app/src/file-pane/source/index.tsx b/packages/app/src/file-pane/source/index.tsx
new file mode 100644
index 0000000000..c31644cfeb
--- /dev/null
+++ b/packages/app/src/file-pane/source/index.tsx
@@ -0,0 +1,39 @@
+import { useTranslation } from "react-i18next";
+import { useMemo } from "react";
+import { UnistylesRuntime } from "react-native-unistyles";
+import type { WorkspaceFileLocation } from "@/workspace/file-open";
+import { FileSourceView } from "./view";
+
+/** Read-only source preview. The source module owns platform rendering and its theme. */
+export function WorkspaceFileSource(props: {
+ content: string;
+ filename: string;
+ location: WorkspaceFileLocation;
+ navigationRevision: number;
+ size: number;
+}) {
+ const { t } = useTranslation();
+ const theme = UnistylesRuntime.getTheme();
+ const visualTheme = useMemo(
+ () => ({
+ colorScheme: theme.colorScheme,
+ background: theme.colors.surface0,
+ foreground: theme.colors.foreground,
+ cursor: theme.colors.terminal.cursor,
+ foregroundMuted: theme.colors.foregroundMuted,
+ border: theme.colors.border,
+ selection: theme.colors.terminal.selectionBackground,
+ monoFont: theme.fontFamily.mono,
+ codeFontSize: theme.fontSize.code,
+ syntax: theme.colors.syntax,
+ }),
+ [theme],
+ );
+ return (
+
+ );
+}
diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx
index de7c5eeae1..3ef40c93d6 100644
--- a/packages/app/src/file-pane/source/view.native.tsx
+++ b/packages/app/src/file-pane/source/view.native.tsx
@@ -1,5 +1,6 @@
-import { useEffect, useMemo, useRef } from "react";
-import { FlatList, Text, View, type ListRenderItem } from "react-native";
+import { useTranslation } from "react-i18next";
+import { useCallback, useEffect, useMemo, useRef } from "react";
+import { FlatList, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
@@ -58,14 +59,16 @@ function VirtualizedSource({
}: Omit
& {
presentation: "highlighted" | "plain";
}) {
+ const { t } = useTranslation();
const listRef = useRef>(null);
const lines = useMemo(() => {
+ const source = content.replace(/\r\n?/g, "\n");
if (presentation === "highlighted")
- return highlightCode(content, filename).map((tokens, index) => ({
+ return highlightCode(source, filename).map((tokens, index) => ({
number: index + 1,
tokens,
}));
- return content
+ return source
.split("\n")
.map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] }));
}, [content, filename, presentation]);
@@ -77,29 +80,63 @@ function VirtualizedSource({
viewPosition: 0.5,
});
}, [lines.length, location.lineStart, navigationRevision]);
+ const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens
+ .map((token) => token.text)
+ .join("");
+ const changed =
+ location.expectedText !== undefined &&
+ selectedLine?.slice((location.columnStart ?? 1) - 1, (location.columnEnd ?? 1) - 1) !==
+ location.expectedText;
+ const renderLine = useCallback(
+ ({ item }: { item: SourceLine }) => (
+
+ ),
+ [changed, location],
+ );
return (
-
+
+ {changed ? (
+
+ {t("shell.commandCenter.contentChanged")}
+
+ ) : null}
+
+
);
}
-function SourceLineView({ line }: { line: SourceLine }) {
+function SourceLineView({ line, start, end }: { line: SourceLine; start?: number; end?: number }) {
+ let offset = 0;
return (
{line.number}
- {line.tokens.map((token) => (
-
- {token.text}
-
- ))}
+ {line.tokens.map((token) => {
+ const tokenStart = offset;
+ offset += token.text.length;
+ const from = Math.max(0, Math.min(token.text.length, (start ?? 1) - 1 - tokenStart));
+ const to = Math.max(from, Math.min(token.text.length, (end ?? 1) - 1 - tokenStart));
+ return (
+
+ {token.text.slice(0, from)}
+ {token.text.slice(from, to)}
+ {token.text.slice(to)}
+
+ );
+ })}
);
@@ -113,9 +150,14 @@ function sourceLineLayout(_data: ArrayLike | null | undefined, index
return { length: 20, offset: index * 20, index };
}
-const renderSourceLine: ListRenderItem = ({ item }) => ;
-
const styles = StyleSheet.create((theme) => ({
+ root: { flex: 1 },
+ notice: {
+ padding: theme.spacing[2],
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ },
+ match: { backgroundColor: theme.colors.terminal.selectionBackground },
unsupported: {
flex: 1,
alignItems: "center",
diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx
index 3499ac7d8e..bc2c346046 100644
--- a/packages/app/src/file-pane/source/view.web.tsx
+++ b/packages/app/src/file-pane/source/view.web.tsx
@@ -1,9 +1,10 @@
+import { useTranslation } from "react-i18next";
import { useEffect, useRef, useState } from "react";
import { FileFind, FileFindModel } from "../find/index.web";
import { Compartment, EditorState } from "@codemirror/state";
-import { EditorView } from "@codemirror/view";
+import { EditorView, drawSelection } from "@codemirror/view";
import { getLanguageForFile } from "@getpaseo/highlight";
-import type { WorkspaceFileLocation } from "@/workspace/file-open";
+import { resolveWorkspaceFileSelection, type WorkspaceFileLocation } from "@/workspace/file-open";
import type { EditorVisualTheme } from "../editor/extensions.web";
import { editorTheme } from "../editor/extensions.web";
import { selectSourcePresentation, type SourcePresentation } from "./presentation";
@@ -60,6 +61,7 @@ function ReadonlyCodeMirror({
}: Omit & {
presentation: Exclude;
}) {
+ const { t } = useTranslation();
const [find] = useState(() => new FileFindModel());
const hostRef = useRef(null);
const viewRef = useRef(null);
@@ -79,6 +81,9 @@ function ReadonlyCodeMirror({
tabindex: "0",
"aria-label": `Source for ${values.filename}`,
}),
+ // The preview is never focused, so the browser draws no selection of its own; the
+ // exact occurrence has to be painted by CodeMirror.
+ drawSelection(),
EditorView.editable.of(false),
languageCompartment.of(
languageFor({ filename: values.filename, presentation: values.presentation }),
@@ -112,13 +117,17 @@ function ReadonlyCodeMirror({
useEffect(() => {
const view = viewRef.current;
if (!view || !location.lineStart) return;
- const line = Math.min(location.lineStart, view.state.doc.lines);
- const from = view.state.doc.line(line).from;
- view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) });
- }, [location.lineStart, navigationRevision]);
+ const { from, to } = resolveWorkspaceFileSelection(view.state.doc.toString(), location);
+ view.dispatch({
+ selection: { anchor: from, head: to },
+ effects: EditorView.scrollIntoView(from, { y: "center" }),
+ });
+ }, [content, location, navigationRevision]);
+ const changed = resolveWorkspaceFileSelection(content.replace(/\r\n?/g, "\n"), location).changed;
return (
+ {changed ?
{t("shell.commandCenter.contentChanged")}
: null}
@@ -134,8 +143,11 @@ function languageFor(input: {
: [];
}
+// The Find widget floats inside this frame, so it stays relative; the change notice is the
+// only row that shares the column with the editor host.
const FRAME_STYLE = {
display: "flex",
+ flexDirection: "column",
position: "relative",
flex: 1,
minHeight: 0,
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 5e21b1a089..b1eddfaa74 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -73,6 +73,21 @@ export const ar: TranslationResources = {
close: "إغلاق القائمة",
},
commandCenter: {
+ contentTitle: "البحث في محتوى الملفات",
+ contentPlaceholder: "البحث في الملفات المحفوظة...",
+ contentUnavailable: "مساحة العمل غير متاحة",
+ contentUpdateHost: "حدّث هذا المضيف للبحث في محتوى الملفات",
+ contentEmpty: "البحث في الملفات المحفوظة",
+ contentBack: "العودة إلى النتائج",
+ contentPreview: "معاينة",
+ contentOpen: "فتح",
+ contentLoadingPreview: "جارٍ تحميل المعاينة...",
+ contentLimited: "النتائج محدودة — حدّد البحث أكثر",
+ contentScopeDetail:
+ "يبحث في ملفات UTF-8 المحفوظة، بما فيها المخفية وغير المتتبعة. تُطبق قواعد التجاهل. لا يشمل الروابط الرمزية والتعديلات غير المحفوظة.",
+ contentScope: "ملفات محفوظة حتى 1 MiB · تجاهل حالة الأحرف",
+ contentChanged: "تغيّر الملف — لم تعد المطابقة في هذا الموضع",
+
placeholder: "ابحث في الأوامر والملفات ومساحات العمل والوكلاء...",
filePlaceholder: "البحث في الملفات...",
searchingFiles: "جارٍ البحث في الملفات...",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index acb8f94b74..407ae0bf1d 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -70,6 +70,21 @@ export const en = {
close: "Close menu",
},
commandCenter: {
+ contentTitle: "Search file contents",
+ contentPlaceholder: "Search saved file contents...",
+ contentUnavailable: "Workspace unavailable",
+ contentUpdateHost: "Update this host to search file contents",
+ contentEmpty: "Search saved file contents",
+ contentBack: "Back to results",
+ contentPreview: "Preview",
+ contentOpen: "Open",
+ contentLoadingPreview: "Loading preview...",
+ contentLimited: "Results limited — refine your search",
+ contentScopeDetail:
+ "Searches saved UTF-8 files, including hidden and untracked files. Ignore rules apply. Symbolic links and unsaved edits are excluded.",
+ contentScope: "Saved files up to 1 MiB · Ignore case",
+ contentChanged: "File changed — saved match is no longer at this location",
+
placeholder: "Search commands, files, workspaces, and agents...",
filePlaceholder: "Search files...",
searchingFiles: "Searching files...",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 2cf3568a6d..8e156344ce 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -73,6 +73,21 @@ export const es: TranslationResources = {
close: "Cerrar menú",
},
commandCenter: {
+ contentTitle: "Buscar contenido de archivos",
+ contentPlaceholder: "Buscar contenido guardado...",
+ contentUnavailable: "Espacio de trabajo no disponible",
+ contentUpdateHost: "Actualiza este host para buscar contenido",
+ contentEmpty: "Buscar contenido guardado",
+ contentBack: "Volver a resultados",
+ contentPreview: "Vista previa",
+ contentOpen: "Abrir",
+ contentLoadingPreview: "Cargando vista previa...",
+ contentLimited: "Resultados limitados — precisa la búsqueda",
+ contentScopeDetail:
+ "Busca archivos UTF-8 guardados, incluidos ocultos y sin seguimiento. Respeta las reglas de exclusión. Excluye enlaces simbólicos y cambios sin guardar.",
+ contentScope: "Archivos guardados hasta 1 MiB · Sin distinguir mayúsculas",
+ contentChanged: "El archivo cambió — la coincidencia ya no está aquí",
+
placeholder: "Buscar comandos, archivos, espacios de trabajo y agentes...",
filePlaceholder: "Buscar archivos...",
searchingFiles: "Buscando archivos...",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 440a843968..cea53a4f87 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -74,6 +74,21 @@ export const fr: TranslationResources = {
close: "Fermer le menu",
},
commandCenter: {
+ contentTitle: "Rechercher dans les fichiers",
+ contentPlaceholder: "Rechercher dans les fichiers enregistrés...",
+ contentUnavailable: "Espace de travail indisponible",
+ contentUpdateHost: "Mettez cet hôte à jour pour rechercher dans les fichiers",
+ contentEmpty: "Rechercher dans les fichiers enregistrés",
+ contentBack: "Retour aux résultats",
+ contentPreview: "Aperçu",
+ contentOpen: "Ouvrir",
+ contentLoadingPreview: "Chargement de l’aperçu...",
+ contentLimited: "Résultats limités — précisez la recherche",
+ contentScopeDetail:
+ "Recherche dans les fichiers UTF-8 enregistrés, y compris cachés et non suivis. Respecte les règles d’exclusion. Exclut les liens symboliques et les modifications non enregistrées.",
+ contentScope: "Fichiers enregistrés jusqu’à 1 Mio · Casse ignorée",
+ contentChanged: "Fichier modifié — la correspondance n’est plus à cet emplacement",
+
placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...",
filePlaceholder: "Rechercher des fichiers...",
searchingFiles: "Recherche de fichiers...",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index eef67e031d..b408a69fd6 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -73,6 +73,21 @@ export const ja: TranslationResources = {
close: "メニューを閉じる",
},
commandCenter: {
+ contentTitle: "ファイル内容を検索",
+ contentPlaceholder: "保存済みファイルの内容を検索...",
+ contentUnavailable: "ワークスペースを利用できません",
+ contentUpdateHost: "ファイル内容を検索するにはホストを更新してください",
+ contentEmpty: "保存済みファイルの内容を検索",
+ contentBack: "検索結果に戻る",
+ contentPreview: "プレビュー",
+ contentOpen: "開く",
+ contentLoadingPreview: "プレビューを読み込み中...",
+ contentLimited: "結果が上限に達しました — 検索を絞り込んでください",
+ contentScopeDetail:
+ "非表示・未追跡のファイルを含む保存済み UTF-8 ファイルを検索します。除外ルールに従い、シンボリックリンクと未保存の編集は検索しません。",
+ contentScope: "1 MiB 以下の保存済みファイル · 大文字小文字を区別しない",
+ contentChanged: "ファイルが変更され、この位置に一致する内容はありません",
+
placeholder: "コマンド、ファイル、ワークスペース、エージェントを検索...",
filePlaceholder: "ファイルを検索...",
searchingFiles: "ファイルを検索中...",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index fbe8970665..d5eac90fb3 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -73,6 +73,21 @@ export const ko: TranslationResources = {
close: "메뉴 닫기",
},
commandCenter: {
+ contentTitle: "파일 내용 검색",
+ contentPlaceholder: "저장된 파일 내용 검색...",
+ contentUnavailable: "작업 공간을 사용할 수 없음",
+ contentUpdateHost: "파일 내용을 검색하려면 호스트를 업데이트하세요",
+ contentEmpty: "저장된 파일 내용 검색",
+ contentBack: "결과로 돌아가기",
+ contentPreview: "미리보기",
+ contentOpen: "열기",
+ contentLoadingPreview: "미리보기 로딩 중...",
+ contentLimited: "결과 제한 — 검색 범위를 좁히세요",
+ contentScopeDetail:
+ "숨김 파일과 추적되지 않은 파일을 포함한 저장된 UTF-8 파일을 검색합니다. 무시 규칙을 따르며 심볼릭 링크와 저장되지 않은 편집은 제외합니다.",
+ contentScope: "1 MiB 이하 저장된 파일 · 대소문자 구분 안 함",
+ contentChanged: "파일이 변경되어 이 위치에 일치하는 내용이 없습니다",
+
placeholder: "명령, 파일, 워크스페이스 및 에이전트 검색...",
filePlaceholder: "파일 검색...",
searchingFiles: "파일 검색 중...",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index b906b4d6e3..7ec42f2914 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -73,6 +73,21 @@ export const ptBR: TranslationResources = {
close: "Fechar menu",
},
commandCenter: {
+ contentTitle: "Buscar conteúdo dos arquivos",
+ contentPlaceholder: "Buscar conteúdo salvo...",
+ contentUnavailable: "Espaço de trabalho indisponível",
+ contentUpdateHost: "Atualize este host para buscar conteúdo",
+ contentEmpty: "Buscar conteúdo salvo",
+ contentBack: "Voltar aos resultados",
+ contentPreview: "Prévia",
+ contentOpen: "Abrir",
+ contentLoadingPreview: "Carregando prévia...",
+ contentLimited: "Resultados limitados — refine a busca",
+ contentScopeDetail:
+ "Pesquisa arquivos UTF-8 salvos, incluindo ocultos e não rastreados. Respeita as regras de exclusão. Exclui links simbólicos e edições não salvas.",
+ contentScope: "Arquivos salvos até 1 MiB · Ignorar maiúsculas",
+ contentChanged: "Arquivo alterado — a correspondência não está mais neste local",
+
placeholder: "Buscar comandos, arquivos, espaços de trabalho e agentes...",
filePlaceholder: "Pesquisar arquivos...",
searchingFiles: "Pesquisando arquivos...",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 76b066154a..dcab559c2a 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -73,6 +73,21 @@ export const ru: TranslationResources = {
close: "Закрыть меню",
},
commandCenter: {
+ contentTitle: "Поиск по содержимому файлов",
+ contentPlaceholder: "Поиск в сохранённых файлах...",
+ contentUnavailable: "Рабочее пространство недоступно",
+ contentUpdateHost: "Обновите этот хост для поиска по содержимому",
+ contentEmpty: "Поиск в сохранённых файлах",
+ contentBack: "Назад к результатам",
+ contentPreview: "Предпросмотр",
+ contentOpen: "Открыть",
+ contentLoadingPreview: "Загрузка предпросмотра...",
+ contentLimited: "Результаты ограничены — уточните поиск",
+ contentScopeDetail:
+ "Поиск в сохранённых файлах UTF-8, включая скрытые и неотслеживаемые. Учитываются правила исключения. Символические ссылки и несохранённые изменения исключены.",
+ contentScope: "Сохранённые файлы до 1 МиБ · Без учёта регистра",
+ contentChanged: "Файл изменён — совпадения больше нет в этом месте",
+
placeholder: "Поиск команд, файлов, рабочих пространств и агентов...",
filePlaceholder: "Поиск файлов...",
searchingFiles: "Поиск файлов...",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index cc3dc52587..abf7478509 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -73,6 +73,21 @@ export const zhCN: TranslationResources = {
close: "关闭菜单",
},
commandCenter: {
+ contentTitle: "搜索文件内容",
+ contentPlaceholder: "搜索已保存的文件内容...",
+ contentUnavailable: "工作区不可用",
+ contentUpdateHost: "请更新此主机以搜索文件内容",
+ contentEmpty: "搜索已保存的文件内容",
+ contentBack: "返回结果",
+ contentPreview: "预览",
+ contentOpen: "打开",
+ contentLoadingPreview: "正在加载预览...",
+ contentLimited: "结果已达上限 — 请缩小搜索范围",
+ contentScopeDetail:
+ "搜索已保存的 UTF-8 文件,包括隐藏和未跟踪的文件。遵循忽略规则,不搜索符号链接和未保存的编辑。",
+ contentScope: "不超过 1 MiB 的已保存文件 · 忽略大小写",
+ contentChanged: "文件已更改 — 此位置不再匹配",
+
placeholder: "搜索命令、文件、工作区和 Agent...",
filePlaceholder: "搜索文件...",
searchingFiles: "正在搜索文件...",
diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts
index 1bf78d923f..8fb926eb68 100644
--- a/packages/app/src/keyboard/actions.ts
+++ b/packages/app/src/keyboard/actions.ts
@@ -46,6 +46,7 @@ export type KeyboardActionId =
| "settings.toggle"
| "command-center.toggle"
| "command-center.files"
+ | "command-center.content"
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
index 02e23ba614..beca67864e 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts
@@ -1196,3 +1196,20 @@ describe("direct new-tab target shortcuts", () => {
).toEqual([["ctrl", "shift", "H"]]);
});
});
+
+it("searches contents from terminal focus while preserving explicit Focus overrides", () => {
+ const event = { key: "F", code: "KeyF", metaKey: true, shiftKey: true };
+ const context = { isMac: true, isDesktop: true, focusScope: "terminal" as const };
+ expect(resolveShortcut({ event, context }).match?.action).toBe("command-center.content");
+ expect(resolveShortcut({ event: { ...event, altKey: true }, context }).match?.action).toBe(
+ "view.toggle.focus",
+ );
+ const bindings = buildEffectiveBindings({ "view-toggle-focus-cmd-shift-f-mac": "Cmd+Shift+F" });
+ expect(resolveShortcut({ event, context, bindings }).match?.action).toBe("view.toggle.focus");
+ const disabled = buildEffectiveBindings({
+ "view-toggle-focus-cmd-shift-f-mac": UNASSIGNED_COMBO,
+ });
+ expect(
+ resolveShortcut({ event: { ...event, altKey: true }, context, bindings: disabled }).match,
+ ).toBeNull();
+});
diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts
index d18650a80a..882f016b33 100644
--- a/packages/app/src/keyboard/keyboard-shortcuts.ts
+++ b/packages/app/src/keyboard/keyboard-shortcuts.ts
@@ -156,6 +156,7 @@ export const SHORTCUT_HELP_ROW_ORDER: Record = {
"toggle-right-sidebar": "settings.shortcuts.help.toggleRightSidebar",
"toggle-both-sidebars": "settings.shortcuts.help.toggleBothSidebars",
"toggle-settings": "settings.shortcuts.help.toggleSettings",
+ "search-workspace-content": "shell.commandCenter.contentTitle",
"toggle-focus": "settings.shortcuts.help.toggleFocusMode",
"cycle-theme": "settings.shortcuts.help.cycleTheme",
"focus-message-input": "settings.shortcuts.help.focusMessageInput",
@@ -1003,7 +1005,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-cmd-shift-f-mac",
action: "view.toggle.focus",
- combo: "Cmd+Shift+F",
+ combo: "Cmd+Alt+Shift+F",
when: { mac: true, commandCenter: false },
help: {
id: "toggle-focus",
@@ -1014,7 +1016,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
{
id: "view-toggle-focus-ctrl-shift-f-non-mac",
action: "view.toggle.focus",
- combo: "Ctrl+Shift+F",
+ combo: "Ctrl+Alt+Shift+F",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "toggle-focus",
@@ -1023,6 +1025,21 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
+ {
+ id: "command-center-content-cmd-shift-f-mac",
+ action: "command-center.content",
+ combo: "Cmd+Shift+F",
+ when: { mac: true, commandCenter: false },
+ help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
+ },
+ {
+ id: "command-center-content-ctrl-shift-f-non-mac",
+ action: "command-center.content",
+ combo: "Ctrl+Shift+F",
+ when: { mac: false, commandCenter: false },
+ help: { id: "search-workspace-content", section: "general", label: "Search file contents" },
+ },
+
// --- Theme cycling ---
{
id: "theme-cycle-cmd-shift-t-mac",
diff --git a/packages/app/src/keyboard/route-shortcut.ts b/packages/app/src/keyboard/route-shortcut.ts
index aa7679a7bf..6aa83fcf81 100644
--- a/packages/app/src/keyboard/route-shortcut.ts
+++ b/packages/app/src/keyboard/route-shortcut.ts
@@ -32,7 +32,7 @@ export type ShortcutAction =
| { kind: "router-push"; route: string }
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
- | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" }
+ | { kind: "command-center-toggle"; nextOpen: boolean; scope?: "files" | "content" }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
const NONE: ShortcutAction = { kind: "none" };
@@ -209,6 +209,10 @@ export function routeKeyboardShortcut(
return routeSettingsToggle(ctx);
case "command-center.toggle":
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
+ case "command-center.content":
+ if (parseHostWorkspaceRouteFromPathname(ctx.pathname))
+ return { kind: "command-center-toggle", nextOpen: true, scope: "content" };
+ return dispatch({ id: "workspace.project.pick", scope: "workspace" });
case "command-center.files":
if (parseHostWorkspaceRouteFromPathname(ctx.pathname)) {
return { kind: "command-center-toggle", nextOpen: true, scope: "files" };
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts
index 0b0e2a516c..825eb08b51 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.ts
@@ -3,7 +3,7 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
-export type CommandCenterScope = "files" | null;
+export type CommandCenterScope = "files" | "content" | null;
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts
index 974b8c6964..9281950eaf 100644
--- a/packages/app/src/stores/workspace-layout-store.ts
+++ b/packages/app/src/stores/workspace-layout-store.ts
@@ -207,6 +207,9 @@ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
path: z.string(),
lineStart: z.number().int().positive().optional(),
lineEnd: z.number().int().positive().optional(),
+ columnStart: z.number().int().positive().optional(),
+ columnEnd: z.number().int().positive().optional(),
+ expectedText: z.string().optional(),
}),
z.strictObject({
kind: z.literal("working_diff"),
diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts
index d7d2dd3976..2c75963a40 100644
--- a/packages/app/src/workspace/file-open/index.test.ts
+++ b/packages/app/src/workspace/file-open/index.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
+ resolveWorkspaceFileSelection,
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
resolveWorkspaceFilePaths,
@@ -34,7 +35,7 @@ describe("normalizeWorkspaceFileLocation", () => {
});
it("rejects empty paths", () => {
- expect(normalizeWorkspaceFileLocation({ path: " " })).toBeNull();
+ expect(normalizeWorkspaceFileLocation({ path: "" })).toBeNull();
});
});
@@ -183,3 +184,45 @@ describe("resolveWorkspaceFilePaths", () => {
).toBeNull();
});
});
+
+it("selects exact UTF-16 columns and refuses a stale saved match", () => {
+ const location = {
+ path: "a.ts",
+ lineStart: 2,
+ columnStart: 5,
+ columnEnd: 11,
+ expectedText: "NEEDLE",
+ };
+ expect(resolveWorkspaceFileSelection("first\né🙂 NEEDLE", location)).toEqual({
+ from: 10,
+ to: 16,
+ changed: false,
+ });
+ expect(resolveWorkspaceFileSelection("first\né🙂 change", location)).toEqual({
+ from: 10,
+ to: 10,
+ changed: true,
+ });
+ expect(workspaceFileLocationsEqual(location, { ...location, columnStart: 6 })).toBe(false);
+});
+
+it("does not select an identical occurrence on another line after the saved line disappears", () => {
+ expect(
+ resolveWorkspaceFileSelection("needle", {
+ path: "a.ts",
+ lineStart: 3,
+ columnStart: 1,
+ columnEnd: 7,
+ expectedText: "needle",
+ }),
+ ).toEqual({ from: 0, to: 0, changed: true });
+});
+
+it("preserves literal filename and workspace whitespace through location and host paths", () => {
+ const location = { path: " leading.txt ", lineStart: 1 };
+ expect(normalizeWorkspaceFileLocation(location)).toEqual(location);
+ expect(resolveWorkspaceFilePaths({ path: location.path, workspaceRoot: "/workspace " })).toEqual({
+ absolutePath: "/workspace / leading.txt ",
+ relativePath: " leading.txt ",
+ });
+});
diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts
index f09d5bd81f..a777871638 100644
--- a/packages/app/src/workspace/file-open/index.ts
+++ b/packages/app/src/workspace/file-open/index.ts
@@ -4,8 +4,15 @@ export type OpenFileDisposition = "main" | "preferred" | "side";
export interface WorkspaceFileLocation {
path: string;
+ /** One-based source line; CRLF, CR and LF each separate lines. */
lineStart?: number;
lineEnd?: number;
+ /** One-based UTF-16 column on lineStart. */
+ columnStart?: number;
+ /** Exclusive UTF-16 column on the same line. */
+ columnEnd?: number;
+ /** Original saved occurrence; a mismatch reveals the location without selecting text. */
+ expectedText?: string;
}
export type WorkspaceFileTabTarget = { kind: "file" } & WorkspaceFileLocation;
@@ -22,16 +29,21 @@ export function normalizeWorkspaceFileLocation(
return null;
}
- const path = location.path.trim().replace(/\\/g, "/");
+ const path = location.path.replace(/\\/g, "/");
if (!path) {
return null;
}
const lineStart = normalizeLineNumber(location.lineStart);
const lineEnd = normalizeLineNumber(location.lineEnd);
+ const columnStart = normalizeLineNumber(location.columnStart);
+ const columnEnd = normalizeLineNumber(location.columnEnd);
return {
path,
...(lineStart ? { lineStart } : {}),
+ ...(lineStart && columnStart ? { columnStart } : {}),
+ ...(lineStart && columnEnd && columnEnd >= (columnStart ?? 1) ? { columnEnd } : {}),
+ ...(location.expectedText !== undefined ? { expectedText: location.expectedText } : {}),
...(lineStart && lineEnd && lineEnd >= lineStart ? { lineEnd } : {}),
};
}
@@ -41,7 +53,12 @@ export function workspaceFileLocationsEqual(
right: WorkspaceFileLocation,
): boolean {
return (
- left.path === right.path && left.lineStart === right.lineStart && left.lineEnd === right.lineEnd
+ left.path === right.path &&
+ left.lineStart === right.lineStart &&
+ left.lineEnd === right.lineEnd &&
+ left.columnStart === right.columnStart &&
+ left.columnEnd === right.columnEnd &&
+ left.expectedText === right.expectedText
);
}
@@ -145,8 +162,8 @@ export function resolveWorkspaceFilePaths(input: {
path: string;
workspaceRoot: string;
}): ResolvedWorkspaceFilePaths | null {
- const filePath = input.path.trim().replace(/\\/g, "/");
- const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/"));
+ const filePath = input.path.replace(/\\/g, "/");
+ const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.replace(/\\/g, "/"));
if (!filePath || !workspaceRoot) {
return null;
}
@@ -176,3 +193,26 @@ export function resolveWorkspaceFilePaths(input: {
}
return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath };
}
+
+/** Resolves a saved location without manufacturing a selection when its text has moved. */
+export function resolveWorkspaceFileSelection(
+ content: string,
+ location: WorkspaceFileLocation,
+): { from: number; to: number; changed: boolean } {
+ const lines = content.split("\n");
+ const index = Math.max(0, Math.min((location.lineStart ?? 1) - 1, lines.length - 1));
+ const line = lines[index];
+ const offset = lines.slice(0, index).reduce((sum, value) => sum + value.length + 1, 0);
+ const from = offset + Math.min((location.columnStart ?? 1) - 1, line.length);
+ const endIndex = Math.max(index, Math.min((location.lineEnd ?? index + 1) - 1, lines.length - 1));
+ let to = from;
+ if (location.columnEnd !== undefined) to = offset + Math.min(location.columnEnd - 1, line.length);
+ else if (endIndex > index)
+ to =
+ lines.slice(0, endIndex).reduce((sum, value) => sum + value.length + 1, 0) +
+ lines[endIndex].length;
+ const changed =
+ location.expectedText !== undefined &&
+ (index !== (location.lineStart ?? 1) - 1 || content.slice(from, to) !== location.expectedText);
+ return { from, to: changed ? from : to, changed };
+}
diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts
index e22534da35..b423982da1 100644
--- a/packages/client/src/daemon-client.ts
+++ b/packages/client/src/daemon-client.ts
@@ -1,3 +1,4 @@
+import type { WorkspaceContentSearchResult } from "@getpaseo/protocol/messages";
import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import {
@@ -4491,6 +4492,33 @@ export class DaemonClient {
return payload.directory;
}
+ async searchWorkspaceContent(
+ input: { cwd: string; query: string },
+ options: { signal?: AbortSignal } = {},
+ ): Promise {
+ if (options.signal?.aborted)
+ return { status: "error", code: "cancelled", message: "Search cancelled" };
+ const requestId = this.createRequestId();
+ const cancel = () => {
+ void this.sendCorrelatedSessionRequest({
+ message: { type: "fs.content.cancel.request", searchRequestId: requestId },
+ responseType: "fs.content.cancel.response",
+ }).catch(() => undefined);
+ };
+ const response = this.sendCorrelatedSessionRequest({
+ requestId,
+ message: { type: "fs.content.search.request", ...input },
+ responseType: "fs.content.search.response",
+ });
+ options.signal?.addEventListener("abort", cancel, { once: true });
+ if (options.signal?.aborted) cancel();
+ try {
+ return (await response).result;
+ } finally {
+ options.signal?.removeEventListener("abort", cancel);
+ }
+ }
+
async readFile(
cwd: string,
path: string,
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 7068cdc0bf..45685e0e76 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -1,3 +1,4 @@
+export type { FileReadResult } from "./daemon-client.js";
import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type {
diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts
index ed36eeb1ad..7afcffd563 100644
--- a/packages/protocol/src/messages.ts
+++ b/packages/protocol/src/messages.ts
@@ -2688,6 +2688,50 @@ export const FileVersionSchema = z.discriminatedUnion("status", [
}),
]);
+export const WorkspaceContentMatchSchema = z.object({
+ path: z.string(),
+ line: z.number().int().positive(),
+ columnStart: z.number().int().positive(),
+ columnEnd: z.number().int().positive(),
+ text: z.string(),
+ snippet: z.string(),
+ snippetMatchStart: z.number().int().nonnegative(),
+ snippetMatchEnd: z.number().int().nonnegative(),
+});
+export const WorkspaceContentSearchResultSchema = z.discriminatedUnion("status", [
+ z.object({
+ status: z.literal("ok"),
+ matches: z.array(WorkspaceContentMatchSchema),
+ limited: z.boolean(),
+ }),
+ z.object({
+ status: z.literal("error"),
+ code: z.enum(["missing_rg", "timeout", "invalid_query", "unavailable", "busy", "cancelled"]),
+ message: z.string(),
+ }),
+]);
+export const WorkspaceContentSearchRequestSchema = z.object({
+ type: z.literal("fs.content.search.request"),
+ requestId: z.string(),
+ cwd: z.string(),
+ query: z.string(),
+});
+export const WorkspaceContentCancelRequestSchema = z.object({
+ type: z.literal("fs.content.cancel.request"),
+ requestId: z.string(),
+ searchRequestId: z.string(),
+});
+export const WorkspaceContentSearchResponseSchema = z.object({
+ type: z.literal("fs.content.search.response"),
+ payload: z.object({ requestId: z.string(), result: WorkspaceContentSearchResultSchema }),
+});
+export const WorkspaceContentCancelResponseSchema = z.object({
+ type: z.literal("fs.content.cancel.response"),
+ payload: z.object({ requestId: z.string(), searchRequestId: z.string() }),
+});
+export type WorkspaceContentMatch = z.infer;
+export type WorkspaceContentSearchResult = z.infer;
+
export const FileSubscribeRequestSchema = z.object({
type: z.literal("fs.file.subscribe.request"),
cwd: z.string(),
@@ -3211,6 +3255,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
WorkspaceClearAttentionRequestSchema,
WorkspaceMarkUnreadRequestSchema,
FileExplorerRequestSchema,
+ WorkspaceContentSearchRequestSchema,
+ WorkspaceContentCancelRequestSchema,
FileSubscribeRequestSchema,
FileUnsubscribeRequestSchema,
FileWriteRequestSchema,
@@ -3516,6 +3562,7 @@ export const ServerInfoStatusPayloadSchema = z
workspaceRecovery: z.boolean().optional(),
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: z.boolean().optional(),
+ workspaceContentSearch: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -6617,6 +6664,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveResponseSchema,
CreatePaseoWorktreeResponseSchema,
FileExplorerResponseSchema,
+ WorkspaceContentSearchResponseSchema,
+ WorkspaceContentCancelResponseSchema,
FileSubscribeResponseSchema,
FileUnsubscribeResponseSchema,
FileWriteResponseSchema,
diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts
index ec6c4db2dd..38430a00b7 100644
--- a/packages/server/src/server/authorization/operation-permissions.ts
+++ b/packages/server/src/server/authorization/operation-permissions.ts
@@ -81,6 +81,8 @@ const INBOUND_PERMISSION = {
fetch_workspaces_request: ["workspace.read", "hub.execute"],
"file.upload.request": "workspace.write",
file_download_token_request: "workspace.read",
+ "fs.content.search.request": "workspace.read",
+ "fs.content.cancel.request": "workspace.read",
file_explorer_request: "workspace.read",
"forge.search.request": "workspace.read",
"fs.entry.create.request": "workspace.write",
@@ -292,6 +294,8 @@ const OUTBOUND_PERMISSION = {
fetch_workspaces_response: ["workspace.read", "hub.execute"],
"file.upload.response": "workspace.write",
file_download_token_response: "workspace.read",
+ "fs.content.search.response": "workspace.read",
+ "fs.content.cancel.response": "workspace.read",
file_explorer_response: "workspace.read",
"forge.search.response": "workspace.read",
"fs.entry.create.response": "workspace.write",
diff --git a/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts
new file mode 100644
index 0000000000..9f134fb581
--- /dev/null
+++ b/packages/server/src/server/daemon-e2e/workspace-content-search.e2e.test.ts
@@ -0,0 +1,39 @@
+import { mkdtemp, writeFile, rm } from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { expect, test } from "vitest";
+import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
+import { DaemonClient } from "../test-utils/daemon-client.js";
+
+test("real client searches saved contents, cancels host work and can search again", async () => {
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-content-rpc-"));
+ const daemon = await createTestPaseoDaemon();
+ const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
+ try {
+ await writeFile(path.join(cwd, "a.ts"), "const original = 'NEEDLE';\n");
+ await client.connect();
+ await client.fetchAgents();
+ const result = await client.searchWorkspaceContent({ cwd, query: "needle" });
+ expect(
+ result.status === "ok" && result.matches.map((match) => [match.path, match.text]),
+ ).toEqual([["a.ts", "NEEDLE"]]);
+ const controller = new AbortController();
+ const searching = client.searchWorkspaceContent(
+ { cwd, query: "absent" },
+ { signal: controller.signal },
+ );
+ controller.abort();
+ expect(await searching).toMatchObject({ status: "error", code: "cancelled" });
+ expect(await client.searchWorkspaceContent({ cwd, query: "missing" })).toEqual({
+ status: "ok",
+ matches: [],
+ limited: false,
+ });
+ const preview = await client.readFile(cwd, "a.ts", undefined, 1_048_576);
+ expect(new TextDecoder().decode(preview.bytes)).toBe("const original = 'NEEDLE';\n");
+ } finally {
+ await client.close();
+ await daemon.close();
+ await rm(cwd, { recursive: true, force: true });
+ }
+}, 30000);
diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts
index ea22ac24b9..83430aefaa 100644
--- a/packages/server/src/server/file-explorer/service.test.ts
+++ b/packages/server/src/server/file-explorer/service.test.ts
@@ -592,3 +592,44 @@ describe("file explorer service", () => {
}
});
});
+
+it("enforces the caller byte ceiling at the opened file for inline and streaming reads", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "paseo-read-budget-"));
+ try {
+ await writeFile(path.join(root, "growing.txt"), "small");
+ const budget = (await stat(path.join(root, "growing.txt"))).size;
+ await appendFile(path.join(root, "growing.txt"), " grew after precheck");
+ await expect(
+ readExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }),
+ ).rejects.toThrow("File is too large to display");
+ await expect(
+ streamExplorerFile({ root, relativePath: "growing.txt", maxBytes: budget }, async () => {
+ throw new Error("Must reject before delivering content");
+ }),
+ ).rejects.toThrow("File is too large to display");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+it("preserves literal whitespace in existing file identities through read and write", async () => {
+ const root = await createTempDir("paseo-literal-path-");
+ try {
+ await writeFile(path.join(root, " leading.txt"), "needle");
+ await writeFile(path.join(root, "leading.txt"), "different file");
+ const file = await readExplorerFile({ root, relativePath: " leading.txt" });
+ expect(file.content).toBe("needle");
+ const written = await writeExplorerFile({
+ root,
+ relativePath: " leading.txt",
+ content: "updated",
+ expectedModifiedAt: file.modifiedAt,
+ expectedRevision: file.revision,
+ });
+ expect(written.status).toBe("written");
+ expect(await readFile(path.join(root, " leading.txt"), "utf8")).toBe("updated");
+ expect(await readFile(path.join(root, "leading.txt"), "utf8")).toBe("different file");
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts
index 5aa4efe974..8e4b93a060 100644
--- a/packages/server/src/server/file-explorer/service.ts
+++ b/packages/server/src/server/file-explorer/service.ts
@@ -15,6 +15,7 @@ export interface ListDirectoryParams {
}
export interface ReadFileParams {
+ maxBytes?: number;
root: string;
relativePath: string;
}
@@ -193,8 +194,9 @@ export async function listDirectoryEntries({
export async function readExplorerFile({
root,
relativePath,
+ maxBytes,
}: ReadFileParams): Promise {
- const file = await readExplorerFileBytes({ root, relativePath });
+ const file = await readExplorerFileBytes({ root, relativePath, maxBytes });
if (file.kind === "image") {
return {
@@ -236,6 +238,7 @@ export async function readExplorerFile({
export async function readExplorerFileBytes({
root,
relativePath,
+ maxBytes,
}: ReadFileParams): Promise {
const filePath = await resolveScopedPath({ root, relativePath });
const handle = await openFileForRead(filePath.resolvedPath);
@@ -247,6 +250,8 @@ export async function readExplorerFileBytes({
throw new Error("Requested path is not a file");
}
+ if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
+ throw new Error("File is too large to display");
const ext = path.extname(filePath.resolvedPath).toLowerCase();
const basePayload = {
path: normalizeRelativePath({ root, targetPath: filePath.requestedPath }),
@@ -255,7 +260,10 @@ export async function readExplorerFileBytes({
revision: fileRevision(stats),
};
- const buffer = await handle.readFile();
+ const chunks: Buffer[] = [];
+ for await (const chunk of readFileHandleChunks(handle, Number(stats.size), fileRevision(stats)))
+ chunks.push(Buffer.from(chunk));
+ const buffer = Buffer.concat(chunks);
if (ext in IMAGE_MIME_TYPES) {
return {
...basePayload,
@@ -289,7 +297,7 @@ export async function readExplorerFileBytes({
}
export async function streamExplorerFile(
- { root, relativePath }: ReadFileParams,
+ { root, relativePath, maxBytes }: ReadFileParams,
consume: (file: FileExplorerFileStream) => Promise,
): Promise {
const filePath = await resolveScopedPath({ root, relativePath });
@@ -301,6 +309,8 @@ export async function streamExplorerFile(
throw new Error("Requested path is not a file");
}
+ if (maxBytes !== undefined && stats.size > BigInt(maxBytes))
+ throw new Error("File is too large to display");
const advertisedSize = Number(stats.size);
const advertisedRevision = fileRevision(stats);
const ext = path.extname(filePath.resolvedPath).toLowerCase();
diff --git a/packages/server/src/server/path-utils.ts b/packages/server/src/server/path-utils.ts
index f85c092f96..bb3c12436e 100644
--- a/packages/server/src/server/path-utils.ts
+++ b/packages/server/src/server/path-utils.ts
@@ -12,19 +12,17 @@ function hasHomePrefix(value: string): boolean {
}
export function expandUserPath(value: string): string {
- const trimmed = value.trim();
- if (hasHomePrefix(trimmed)) {
- return resolve(homedir(), trimmed.slice(2));
+ if (hasHomePrefix(value)) {
+ return resolve(homedir(), value.slice(2));
}
- return resolve(trimmed);
+ return resolve(value);
}
export function resolvePathFromBase(baseCwd: string, requestedPath: string): string {
- const trimmed = requestedPath.trim();
- if (hasHomePrefix(trimmed) || isAbsolute(trimmed)) {
- return expandUserPath(trimmed);
+ if (hasHomePrefix(requestedPath) || isAbsolute(requestedPath)) {
+ return expandUserPath(requestedPath);
}
- return resolve(baseCwd, trimmed);
+ return resolve(baseCwd, requestedPath);
}
export function isSameOrDescendantPath(basePath: string, candidatePath: string): boolean {
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index c1e149ee5e..263d8bf8c7 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -2624,6 +2624,11 @@ export class Session {
source?: object,
): Promise | undefined {
switch (msg.type) {
+ case "fs.content.search.request":
+ return this.workspaceFilesSession.handleContentSearch(msg);
+ case "fs.content.cancel.request":
+ this.workspaceFilesSession.handleContentCancel(msg);
+ return;
case "file_explorer_request":
return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
case "fs.file.subscribe.request":
diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts
index 0f833479c2..5e54008902 100644
--- a/packages/server/src/server/session/files/workspace-files-session.ts
+++ b/packages/server/src/server/session/files/workspace-files-session.ts
@@ -1,3 +1,4 @@
+import { searchWorkspaceContent } from "../../workspace-content-search/index.js";
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import {
@@ -63,6 +64,7 @@ export interface WorkspaceFilesSessionOptions {
* the whole concern.
*/
export class WorkspaceFilesSession {
+ private readonly searches = new Map();
private readonly host: WorkspaceFilesSessionHost;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly logger: pino.Logger;
@@ -78,6 +80,50 @@ export class WorkspaceFilesSession {
this.fileObserver = options.fileObserver ?? workspaceFileObserver;
}
+ async handleContentSearch(
+ request: Extract,
+ ): Promise {
+ if (this.searches.size >= 2 || this.searches.has(request.requestId)) {
+ this.host.emit({
+ type: "fs.content.search.response",
+ payload: {
+ requestId: request.requestId,
+ result: {
+ status: "error",
+ code: "busy",
+ message: "Another search is still stopping — retry",
+ },
+ },
+ });
+ return;
+ }
+ const controller = new AbortController();
+ this.searches.set(request.requestId, controller);
+ try {
+ const result = await searchWorkspaceContent({
+ cwd: request.cwd,
+ query: request.query,
+ signal: controller.signal,
+ });
+ this.host.emit({
+ type: "fs.content.search.response",
+ payload: { requestId: request.requestId, result },
+ });
+ } finally {
+ this.searches.delete(request.requestId);
+ }
+ }
+
+ handleContentCancel(
+ request: Extract,
+ ): void {
+ this.searches.get(request.searchRequestId)?.abort();
+ this.host.emit({
+ type: "fs.content.cancel.response",
+ payload: { requestId: request.requestId, searchRequestId: request.searchRequestId },
+ });
+ }
+
async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise {
this.fileSubscriptions.get(request.subscriptionId)?.();
try {
@@ -214,13 +260,14 @@ export class WorkspaceFilesSession {
}
dispose(): void {
+ for (const controller of this.searches.values()) controller.abort();
for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe();
this.fileSubscriptions.clear();
}
async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise {
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
- const cwd = workspaceCwd.trim();
+ const cwd = workspaceCwd;
if (!cwd) {
this.host.emit(
{
@@ -270,41 +317,45 @@ export class WorkspaceFilesSession {
}
}
if (request.acceptBinary && this.host.hasBinaryChannel()) {
- await streamExplorerFile({ root: cwd, relativePath: requestedPath }, async (file) => {
- await this.host.emitBinary(
- encodeFileTransferFrame({
- opcode: FileTransferOpcode.FileBegin,
- requestId,
- metadata: {
- mime: file.mimeType,
- size: file.size,
- encoding: file.encoding,
- modifiedAt: file.modifiedAt,
- revision: file.revision,
- },
- }),
- source,
- );
- for await (const chunk of file.chunks) {
+ await streamExplorerFile(
+ { root: cwd, relativePath: requestedPath, maxBytes: request.maxBytes },
+ async (file) => {
await this.host.emitBinary(
encodeFileTransferFrame({
- opcode: FileTransferOpcode.FileChunk,
+ opcode: FileTransferOpcode.FileBegin,
requestId,
- payload: chunk,
+ metadata: {
+ mime: file.mimeType,
+ size: file.size,
+ encoding: file.encoding,
+ modifiedAt: file.modifiedAt,
+ revision: file.revision,
+ },
}),
source,
);
- }
- await this.host.emitBinary(
- encodeFileTransferFrame({
- opcode: FileTransferOpcode.FileEnd,
- requestId,
- }),
- source,
- );
- });
+ for await (const chunk of file.chunks) {
+ await this.host.emitBinary(
+ encodeFileTransferFrame({
+ opcode: FileTransferOpcode.FileChunk,
+ requestId,
+ payload: chunk,
+ }),
+ source,
+ );
+ }
+ await this.host.emitBinary(
+ encodeFileTransferFrame({
+ opcode: FileTransferOpcode.FileEnd,
+ requestId,
+ }),
+ source,
+ );
+ },
+ );
} else {
const file = await readExplorerFile({
+ maxBytes: request.maxBytes,
root: cwd,
relativePath: requestedPath,
});
@@ -391,7 +442,7 @@ export class WorkspaceFilesSession {
async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
- const cwd = workspaceCwd.trim();
+ const cwd = workspaceCwd;
if (!cwd) {
this.host.emit({
type: "file_download_token_response",
diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts
index 30fce35b13..535fb727cc 100644
--- a/packages/server/src/server/websocket-server.ts
+++ b/packages/server/src/server/websocket-server.ts
@@ -1712,6 +1712,7 @@ export class VoiceAssistantWebSocketServer {
workspaceRecovery: true,
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
workspaceFileEditing: true,
+ workspaceContentSearch: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
diff --git a/packages/server/src/server/workspace-content-search/index.test.ts b/packages/server/src/server/workspace-content-search/index.test.ts
new file mode 100644
index 0000000000..2837435efd
--- /dev/null
+++ b/packages/server/src/server/workspace-content-search/index.test.ts
@@ -0,0 +1,157 @@
+import { mkdtemp, writeFile, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, expect, test } from "vitest";
+import { searchWorkspaceContent } from "./index.js";
+
+const roots: string[] = [];
+async function workspace(files: Record) {
+ const root = await mkdtemp(path.join(os.tmpdir(), "paseo-content-search-"));
+ roots.push(root);
+ for (const [name, text] of Object.entries(files)) await writeFile(path.join(root, name), text);
+ return root;
+}
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+test("searches saved untracked text literally, ignoring case and ignore rules in a non-Git workspace", async () => {
+ const cwd = await workspace({
+ "a.ts": "é🙂 NEEDLE needle\r\n",
+ "b.ts": "a.b AXB",
+ ".hidden": "Needle",
+ ".gitignore": "ignored\n",
+ ignored: "needle",
+ });
+ const result = await searchWorkspaceContent({ cwd, query: "needle" });
+ expect(result).toEqual({
+ status: "ok",
+ limited: false,
+ matches: [
+ {
+ path: ".hidden",
+ line: 1,
+ columnStart: 1,
+ columnEnd: 7,
+ text: "Needle",
+ snippet: "Needle",
+ snippetMatchStart: 0,
+ snippetMatchEnd: 6,
+ },
+ {
+ path: "a.ts",
+ line: 1,
+ columnStart: 5,
+ columnEnd: 11,
+ text: "NEEDLE",
+ snippet: "é🙂 NEEDLE needle",
+ snippetMatchStart: 4,
+ snippetMatchEnd: 10,
+ },
+ {
+ path: "a.ts",
+ line: 1,
+ columnStart: 12,
+ columnEnd: 18,
+ text: "needle",
+ snippet: "é🙂 NEEDLE needle",
+ snippetMatchStart: 11,
+ snippetMatchEnd: 17,
+ },
+ ],
+ });
+ const literal = await searchWorkspaceContent({ cwd, query: "a.b" });
+ expect(literal.status === "ok" && literal.matches.map((m) => m.text)).toEqual(["a.b"]);
+});
+
+test("reports missing rg, empty search, cancellation and file/result limits", async () => {
+ const cwd = await workspace({
+ "many.txt": "needle\n".repeat(250),
+ oversized: "needle" + " ".repeat(1_048_576),
+ });
+ const result = await searchWorkspaceContent({ cwd, query: "needle" });
+ expect(result.status === "ok" && [result.matches.length, result.limited]).toEqual([200, true]);
+ expect(await searchWorkspaceContent({ cwd, query: "" })).toEqual({
+ status: "ok",
+ matches: [],
+ limited: false,
+ });
+ expect(await searchWorkspaceContent({ cwd, query: "x" }, { env: { PATH: "" } })).toMatchObject({
+ status: "error",
+ code: "missing_rg",
+ });
+ expect(
+ await searchWorkspaceContent({ cwd, query: "x", signal: AbortSignal.abort() }),
+ ).toMatchObject({ status: "error", code: "cancelled" });
+});
+
+test("preserves Unicode case-fold matches and BOM offsets, rejects binary files and bounds long records", async () => {
+ const cwd = await workspace({
+ unicode: "\uFEFFé🙂 K k K ſ S s σ Σ ς\r\n",
+ binary: "needle\0binary",
+ long: "x".repeat(1_040_000) + "needle",
+ });
+ const kelvin = await searchWorkspaceContent({ cwd, query: "k" });
+ expect(kelvin.status === "ok" && kelvin.matches.map((m) => [m.text, m.columnStart])).toEqual([
+ ["K", 5],
+ ["k", 7],
+ ["K", 9],
+ ]);
+ const sigma = await searchWorkspaceContent({ cwd, query: "σ" });
+ expect(sigma.status === "ok" && sigma.matches.map((m) => m.text)).toEqual(["σ", "Σ", "ς"]);
+ const binary = await searchWorkspaceContent({ cwd, query: "needle" });
+ expect(binary.status === "ok" && binary.matches.map((m) => m.path)).toEqual(["long"]);
+});
+
+test("bounds escaped output and reports timeouts and invalid single-line queries", async () => {
+ const cwd = await workspace({ escaped: "\t".repeat(600_000) + "needle" });
+ expect(await searchWorkspaceContent({ cwd, query: "needle" })).toMatchObject({
+ status: "ok",
+ limited: true,
+ });
+ expect(await searchWorkspaceContent({ cwd, query: "x\ny" })).toMatchObject({
+ status: "error",
+ code: "invalid_query",
+ });
+ expect(await searchWorkspaceContent({ cwd, query: "needle" }, { timeoutMs: 0 })).toMatchObject({
+ status: "error",
+ code: "timeout",
+ });
+});
+
+test("uses source lines across CR-only and mixed separators, including unmatched preceding lines", async () => {
+ const cwd = await workspace({
+ "cr.txt": "first\rneedle",
+ "mixed.txt": "unmatched\rprefix\nfirst\r🙂 NEEDLE\r\nend",
+ });
+ const result = await searchWorkspaceContent({ cwd, query: "needle" });
+ expect(
+ result.status === "ok" &&
+ result.matches.map(({ path: filePath, line, columnStart, columnEnd, text }) => ({
+ path: filePath,
+ line,
+ columnStart,
+ columnEnd,
+ text,
+ })),
+ ).toEqual([
+ { path: "cr.txt", line: 2, columnStart: 1, columnEnd: 7, text: "needle" },
+ { path: "mixed.txt", line: 4, columnStart: 4, columnEnd: 10, text: "NEEDLE" },
+ ]);
+});
+
+test("validates UTF-8 beyond the matching record and preserves an embedded BOM character", async () => {
+ const cwd = await workspace({ "valid.txt": "prefix \uFEFFneedle" });
+ await writeFile(
+ path.join(cwd, "invalid.txt"),
+ Buffer.from([110, 101, 101, 100, 108, 101, 10, 255]),
+ );
+ const invalid = await searchWorkspaceContent({ cwd, query: "needle" });
+ expect(invalid.status === "ok" && invalid.matches.map((match) => match.path)).toEqual([
+ "valid.txt",
+ ]);
+ const embedded = await searchWorkspaceContent({ cwd, query: "\uFEFFneedle" });
+ expect(
+ embedded.status === "ok" && embedded.matches.map((match) => [match.text, match.columnStart]),
+ ).toEqual([["\uFEFFneedle", 8]]);
+});
diff --git a/packages/server/src/server/workspace-content-search/index.ts b/packages/server/src/server/workspace-content-search/index.ts
new file mode 100644
index 0000000000..934f43133d
--- /dev/null
+++ b/packages/server/src/server/workspace-content-search/index.ts
@@ -0,0 +1,268 @@
+import { stat } from "node:fs/promises";
+import type { ChildProcess } from "node:child_process";
+import type {
+ WorkspaceContentMatch,
+ WorkspaceContentSearchResult,
+} from "@getpaseo/protocol/messages";
+import { readExplorerFileBytes } from "../file-explorer/service.js";
+import { spawnProcess } from "../../utils/spawn.js";
+
+const MAX_RESULTS = 200;
+const MAX_FILE_BYTES = 1_048_576;
+const MAX_OUTPUT_BYTES = 4 * MAX_FILE_BYTES;
+
+/** Searches the host's saved files; owns rg and every resource it allocates. */
+export async function searchWorkspaceContent(
+ input: { cwd: string; query: string; signal?: AbortSignal },
+ options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
+): Promise {
+ if (input.signal?.aborted) return failure("cancelled", "Search cancelled");
+ if (
+ Buffer.byteLength(input.query) > 1024 ||
+ ["\0", "\r", "\n"].some((character) => input.query.includes(character))
+ ) {
+ return failure("invalid_query", "Search text must be one line, up to 1,024 bytes");
+ }
+ if (!input.query) return { status: "ok", matches: [], limited: false };
+ try {
+ if (!(await stat(input.cwd)).isDirectory())
+ return failure("unavailable", "Workspace directory is unavailable");
+ } catch {
+ return failure("unavailable", "Workspace directory is unavailable");
+ }
+ let child: ChildProcess;
+ try {
+ child = spawnProcess(
+ process.platform === "win32" ? "rg.exe" : "rg",
+ [
+ "--no-config",
+ "--json",
+ "--fixed-strings",
+ "--ignore-case",
+ "--hidden",
+ "--no-require-git",
+ "--glob",
+ "!.git",
+ "--max-filesize",
+ String(MAX_FILE_BYTES),
+ "--sort",
+ "path",
+ "--encoding",
+ "none",
+ "--",
+ input.query,
+ ".",
+ ],
+ { cwd: input.cwd, shell: false, env: options.env, stdio: ["ignore", "pipe", "pipe"] },
+ );
+ } catch (cause) {
+ return spawnFailure(cause);
+ }
+ return new Promise((resolve) => {
+ const matches: RgMatch[] = [];
+ let pending: RgMatch[] = [];
+ let pendingPath = "";
+ let buffer = Buffer.alloc(0);
+ let outputBytes = 0;
+ let stderr = "";
+ let limited = false;
+ let stopped = false;
+ let error: WorkspaceContentSearchResult | null = null;
+ let killTimer: ReturnType | undefined;
+ const stop = () => {
+ if (stopped) return;
+ stopped = true;
+ child.kill();
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 250);
+ killTimer.unref();
+ };
+ const abort = () => {
+ error = failure("cancelled", "Search cancelled");
+ stop();
+ };
+ const timeout = setTimeout(() => {
+ error = failure("timeout", "Search timed out — refine your query or retry");
+ stop();
+ }, options.timeoutMs ?? 5000);
+ input.signal?.addEventListener("abort", abort, { once: true });
+ if (input.signal?.aborted) abort();
+ child.on("error", (cause) => {
+ error ??= spawnFailure(cause);
+ });
+ child.stderr?.on("data", (chunk: Buffer) => {
+ stderr = (stderr + chunk.toString("utf8")).slice(0, 8192);
+ });
+ child.stdout?.on("data", (chunk: Buffer) => {
+ if (stopped) return;
+ outputBytes += chunk.byteLength;
+ if (outputBytes > MAX_OUTPUT_BYTES) {
+ limited = true;
+ stop();
+ return;
+ }
+ buffer = Buffer.concat([buffer, chunk]);
+ let end: number;
+ while ((end = buffer.indexOf(10)) !== -1) {
+ if (stopped) break;
+ const record = buffer.subarray(0, end);
+ buffer = buffer.subarray(end + 1);
+ if (record.byteLength > MAX_FILE_BYTES) {
+ limited = true;
+ stop();
+ break;
+ }
+ try {
+ const event = JSON.parse(record.toString("utf8")) as RgEvent;
+ if (event.type === "begin") {
+ pending = [];
+ pendingPath = event.data.path?.text?.replace(/^\.\//, "") ?? "";
+ } else if (event.type === "match" && pendingPath) {
+ pending.push(
+ ...readMatches(
+ event.data,
+ pendingPath,
+ MAX_RESULTS + 1 - matches.length - pending.length,
+ ),
+ );
+ } else if (event.type === "end") {
+ // rg may emit matches before discovering a NUL byte later in the file.
+ if (event.data.binary_offset == null) matches.push(...pending);
+ pending = [];
+ if (matches.length > MAX_RESULTS) {
+ limited = true;
+ stop();
+ }
+ }
+ } catch {
+ error = failure("unavailable", "Could not read ripgrep search results");
+ stop();
+ }
+ }
+ if (buffer.byteLength > MAX_FILE_BYTES) {
+ limited = true;
+ stop();
+ }
+ });
+ const cleanup = () => {
+ for (const timer of [timeout, killTimer]) clearTimeout(timer);
+ input.signal?.removeEventListener("abort", abort);
+ };
+ child.once("close", async (code) => {
+ if (!error && !limited && code !== 0 && code !== 1)
+ error = failure("unavailable", stderr.trim() || "File search failed");
+ let located: WorkspaceContentMatch[] = [];
+ try {
+ if (!error)
+ located = await locateMatches(input.cwd, matches.slice(0, MAX_RESULTS), () => !!error);
+ } catch {
+ error ??= failure("unavailable", "Files changed while searching — retry");
+ }
+ cleanup();
+ resolve(error ?? { status: "ok", matches: located, limited });
+ });
+ });
+}
+
+interface RgEvent {
+ type: string;
+ data: {
+ path?: { text?: string };
+ lines?: { text?: string };
+ line_number?: number;
+ submatches?: { start: number; end: number }[];
+ binary_offset?: number | null;
+ absolute_offset?: number;
+ };
+}
+
+function readMatches(data: RgEvent["data"], path: string, remaining: number): RgMatch[] {
+ if (!data.lines?.text || !data.line_number || remaining <= 0) return [];
+ const raw = Buffer.from(data.lines.text);
+ return (data.submatches ?? []).slice(0, remaining).map(({ start, end }) => ({
+ path,
+ byteOffset: (data.absolute_offset ?? 0) + start,
+ text: raw.subarray(start, end).toString("utf8"),
+ }));
+}
+
+function failure(
+ code: Extract["code"],
+ message: string,
+): WorkspaceContentSearchResult {
+ return { status: "error", code, message };
+}
+function spawnFailure(cause: unknown): WorkspaceContentSearchResult {
+ return cause instanceof Error && "code" in cause && cause.code === "ENOENT"
+ ? failure(
+ "missing_rg",
+ "Install ripgrep on this host and make rg available on its PATH, then retry",
+ )
+ : failure(
+ "unavailable",
+ cause instanceof Error ? cause.message : "Could not start file search",
+ );
+}
+
+interface RgMatch {
+ path: string;
+ text: string;
+ byteOffset: number;
+}
+
+/** rg counts LF records; source views count CR, CRLF and LF lines. Read each matched
+ * file once through the bounded/revision-checked producer to translate original byte
+ * offsets. A file changed during the search must not manufacture an occurrence. */
+async function locateMatches(
+ cwd: string,
+ matches: RgMatch[],
+ stopped: () => boolean,
+): Promise {
+ const located: WorkspaceContentMatch[] = [];
+ let path = "";
+ let bytes: Uint8Array = new Uint8Array();
+ let valid = false;
+ for (const { byteOffset, ...match } of matches) {
+ if (stopped()) break;
+ if (path !== match.path) {
+ path = match.path;
+ const file = await readExplorerFileBytes({
+ root: cwd,
+ relativePath: path,
+ maxBytes: MAX_FILE_BYTES,
+ });
+ bytes = file.bytes;
+ try {
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ valid = !bytes.includes(0);
+ } catch {
+ valid = false;
+ }
+ }
+ if (!valid) continue;
+ const end = byteOffset + Buffer.byteLength(match.text);
+ if (
+ new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(byteOffset, end)) !==
+ match.text
+ )
+ continue;
+ const prefix = new TextDecoder().decode(bytes.subarray(0, byteOffset)).split(/\r\n|\r|\n/);
+ const column = prefix[prefix.length - 1].length;
+ const line =
+ prefix[prefix.length - 1] +
+ new TextDecoder("utf-8", { ignoreBOM: true })
+ .decode(bytes.subarray(byteOffset))
+ .split(/\r\n|\r|\n/, 1)[0];
+ const snippetStart = Math.max(0, column - 80);
+ const snippet = line.slice(snippetStart, snippetStart + 240);
+ located.push({
+ ...match,
+ line: prefix.length,
+ columnStart: column + 1,
+ columnEnd: column + match.text.length + 1,
+ snippet,
+ snippetMatchStart: column - snippetStart,
+ snippetMatchEnd: Math.min(column + match.text.length - snippetStart, snippet.length),
+ });
+ }
+ return located;
+}