Unverified 提交 381db427 authored 作者: Adeniji Adekunle James's avatar Adeniji Adekunle James 提交者: GitHub

Code View Search & Fullscreen Mode (#1987) (#1988)

Closes #1987 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds file content search to Code View and a fullscreen toggle to focus on code. Users can search across file contents with highlights and snippets, and jump to lines. Addresses Linear #1987. - New Features - File Tree search: debounced input, content search via ripgrep (1MB max per file), highlights in names, expandable snippets with line numbers, match count, and empty/error states. - Fullscreen mode: toggle in toolbar, Esc to exit, locks page scroll. - IPC: search-app-files handler using ripgrep (via @vscode/ripgrep; binary bundled in Forge extraResources) with UTF-8-safe snippet extraction; exposed via IpcClient and preload. - Hook: useSearchAppFiles with React Query. - UI/Types: FileTree now takes appId; added AppFileSearchResult type; selectedFile supports line for navigation. - Tests: e2e covers content search and navigating to the matched line. - CI: set GITHUB_TOKEN to fetch ripgrep binaries. <sup>Written for commit 97142126c549932d58908df5c842f44ae182c94e. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces fast file content search in Code View and fullscreen viewing, wiring UI to IPC and packaging for bundled ripgrep. > > - UI: `FileTree` now supports debounced content search with highlights, expandable snippets, match counts, and line navigation; `selectedFile` carries `line`; `FileEditor` accepts `initialLine` and jumps to it; `CodeView` adds fullscreen toggle (Esc to exit) > - IPC: New `search-app-files` handler using `@vscode/ripgrep` with UTF-8-safe snippets; exposed via `preload` and `IpcClient.searchAppFiles`; adds `AppFileSearchResult` type > - Packaging/CI: Bundle ripgrep binaries via Forge `extraResource` and set `GITHUB_TOKEN` in CI for ripgrep install; add `@vscode/ripgrep` dependency; bump version to `0.33.0-beta.2` > - Tests: New Playwright e2e verifies search results and navigation to matched line > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 97142126c549932d58908df5c842f44ae182c94e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 af522d32
...@@ -44,6 +44,9 @@ jobs: ...@@ -44,6 +44,9 @@ jobs:
cache-dependency-path: package-lock.json cache-dependency-path: package-lock.json
- name: Install node modules - name: Install node modules
run: npm ci --no-audit --no-fund --progress=false run: npm ci --no-audit --no-fund --progress=false
env:
# Required for @vscode/ripgrep to download binaries without hitting GitHub API rate limits
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Presubmit check (e.g. lint, format) - name: Presubmit check (e.g. lint, format)
# do not run this on Windows (it fails and not necessary) # do not run this on Windows (it fails and not necessary)
# Only run on shard 1 to avoid redundant execution # Only run on shard 1 to avoid redundant execution
...@@ -120,6 +123,9 @@ jobs: ...@@ -120,6 +123,9 @@ jobs:
node-version: lts/* node-version: lts/*
- name: Install dependencies - name: Install dependencies
run: npm ci --no-audit --no-fund --progress=false run: npm ci --no-audit --no-fund --progress=false
env:
# Required for @vscode/ripgrep to download binaries without hitting GitHub API rate limits
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download blob reports from GitHub Actions Artifacts - name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
......
import { expect } from "@playwright/test";
import { test, Timeout } from "./helpers/test_helper";
test("file tree search finds content matches and surfaces line numbers", async ({
po,
}) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
await po.goToChatTab();
await po.selectPreviewMode("code");
// Wait for the code view to finish loading files
await expect(
po.page.getByText("Loading files...", { exact: false }),
).toBeHidden({ timeout: Timeout.LONG });
const searchInput = po.page.getByTestId("file-tree-search");
await expect(searchInput).toBeVisible({ timeout: Timeout.MEDIUM });
// Content search should find files whose contents match the query and show line info
await searchInput.fill("import");
const resultItem = po.page.getByText("App.tsx").first();
await expect(resultItem).toBeVisible({ timeout: Timeout.MEDIUM });
// Files are collapsed by default in the new accordion UI, so we need to click to expand
// Find the file name container (the clickable div that toggles expansion)
const fileContainer = resultItem
.locator("xpath=ancestor::div[contains(@class, 'cursor-pointer')]")
.first();
await expect(fileContainer).toBeVisible({ timeout: Timeout.MEDIUM });
// Click on the file name to expand the accordion and show snippets
await fileContainer.click();
// Now the snippets should be visible - find the snippet container
// The snippet is a div with class "ml-12" that contains the code snippet
// Find it by looking for text containing "import" in the expanded section
const snippetContainer = po.page
.locator("div.ml-12")
.filter({ hasText: /import/i })
.first();
await expect(snippetContainer).toBeVisible({ timeout: Timeout.MEDIUM });
// Verify the snippet contains the search query
const snippetText = await snippetContainer.textContent();
expect(snippetText).toContain("import");
// Click on the snippet container to navigate to that line
await snippetContainer.click();
await expect(async () => {
const editorPosition = await po.page.evaluate(() => {
// Find the Monaco editor instance
const editorElement = document.querySelector(".monaco-editor");
if (!editorElement) return null;
// Access Monaco editor via the window object (Monaco editor attaches itself)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const monaco = (window as any).monaco;
if (!monaco) return null;
// Get all editor instances
const editors = monaco.editor.getEditors();
if (editors.length === 0) return null;
// Find the editor instance that corresponds to the file editor
// The file editor should be the one with a model loaded
const editor =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editors.find((e: any) => {
const model = e.getModel();
return model && model.getLineCount() > 0;
}) || editors[0];
const position = editor.getPosition();
return position
? { lineNumber: position.lineNumber, column: position.column }
: null;
});
expect(editorPosition).not.toBeNull();
if (editorPosition) {
// Monaco editor line numbers are 1-indexed
// Verify that we navigated to a valid line (should be at least line 1)
expect(editorPosition.lineNumber).toBeGreaterThanOrEqual(1);
}
}).toPass({ timeout: Timeout.MEDIUM });
});
...@@ -115,7 +115,7 @@ const config: ForgeConfig = { ...@@ -115,7 +115,7 @@ const config: ForgeConfig = {
}, },
asar: true, asar: true,
ignore, ignore,
extraResource: ["node_modules/dugite/git"], extraResource: ["node_modules/dugite/git", "node_modules/@vscode"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/], // ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
}, },
rebuildConfig: { rebuildConfig: {
......
{ {
"name": "dyad", "name": "dyad",
"version": "0.33.0-beta.1", "version": "0.33.0-beta.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.33.0-beta.1", "version": "0.33.0-beta.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.9", "@ai-sdk/amazon-bedrock": "^4.0.9",
...@@ -51,6 +51,7 @@ ...@@ -51,6 +51,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/sdk": "^1.18.0", "@vercel/sdk": "^1.18.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vscode/ripgrep": "^1.17.0",
"ai": "^6.0.14", "ai": "^6.0.14",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
...@@ -8039,6 +8040,18 @@ ...@@ -8039,6 +8040,18 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@vscode/ripgrep": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz",
"integrity": "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"https-proxy-agent": "^7.0.2",
"proxy-from-env": "^1.1.0",
"yauzl": "^2.9.2"
}
},
"node_modules/@vscode/sudo-prompt": { "node_modules/@vscode/sudo-prompt": {
"version": "9.3.1", "version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz",
...@@ -9001,7 +9014,6 @@ ...@@ -9001,7 +9014,6 @@
"version": "0.2.13", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "*" "node": "*"
...@@ -12590,7 +12602,6 @@ ...@@ -12590,7 +12602,6 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"pend": "~1.2.0" "pend": "~1.2.0"
...@@ -18352,7 +18363,6 @@ ...@@ -18352,7 +18363,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/perfect-freehand": { "node_modules/perfect-freehand": {
...@@ -23523,7 +23533,6 @@ ...@@ -23523,7 +23533,6 @@
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-crc32": "~0.2.3", "buffer-crc32": "~0.2.3",
......
...@@ -128,6 +128,7 @@ ...@@ -128,6 +128,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vercel/sdk": "^1.18.0", "@vercel/sdk": "^1.18.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vscode/ripgrep": "^1.17.0",
"ai": "^6.0.14", "ai": "^6.0.14",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
......
...@@ -3,6 +3,7 @@ import { atom } from "jotai"; ...@@ -3,6 +3,7 @@ import { atom } from "jotai";
export const isPreviewOpenAtom = atom(true); export const isPreviewOpenAtom = atom(true);
export const selectedFileAtom = atom<{ export const selectedFileAtom = atom<{
path: string; path: string;
line?: number | null;
} | null>(null); } | null>(null);
export const activeSettingsSectionAtom = atom<string | null>( export const activeSettingsSectionAtom = atom<string | null>(
"general-settings", "general-settings",
......
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
import { FileTree } from "./FileTree"; import { FileTree } from "./FileTree";
import { RefreshCw } from "lucide-react"; import { useEffect, useState } from "react";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
import { RefreshCw, Maximize2, Minimize2 } from "lucide-react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { selectedFileAtom } from "@/atoms/viewAtoms"; import { selectedFileAtom } from "@/atoms/viewAtoms";
...@@ -19,6 +20,27 @@ export interface CodeViewProps { ...@@ -19,6 +20,27 @@ export interface CodeViewProps {
export const CodeView = ({ loading, app }: CodeViewProps) => { export const CodeView = ({ loading, app }: CodeViewProps) => {
const selectedFile = useAtomValue(selectedFileAtom); const selectedFile = useAtomValue(selectedFileAtom);
const { refreshApp } = useLoadApp(app?.id ?? null); const { refreshApp } = useLoadApp(app?.id ?? null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
if (!isFullscreen) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener("keydown", handleKeyDown);
};
}, [isFullscreen]);
if (loading) { if (loading) {
return <div className="text-center py-4">Loading files...</div>; return <div className="text-center py-4">Loading files...</div>;
...@@ -32,7 +54,9 @@ export const CodeView = ({ loading, app }: CodeViewProps) => { ...@@ -32,7 +54,9 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
if (app.files && app.files.length > 0) { if (app.files && app.files.length > 0) {
return ( return (
<div className="flex flex-col h-full"> <div
className={`flex flex-col bg-background ${isFullscreen ? "fixed inset-0 z-50 h-screen w-screen shadow-2xl" : "h-full"}`}
>
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center p-2 border-b space-x-2"> <div className="flex items-center p-2 border-b space-x-2">
<button <button
...@@ -44,16 +68,28 @@ export const CodeView = ({ loading, app }: CodeViewProps) => { ...@@ -44,16 +68,28 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </button>
<div className="text-sm text-gray-500">{app.files.length} files</div> <div className="text-sm text-gray-500">{app.files.length} files</div>
<div className="flex-1" />
<button
onClick={() => setIsFullscreen((value) => !value)}
className="p-1 rounded hover:bg-gray-200"
title={isFullscreen ? "Exit full screen" : "Enter full screen"}
>
{isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
</button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="w-1/3 overflow-auto border-r"> <div className="w-1/3 border-r overflow-hidden flex flex-col min-h-0">
<FileTree files={app.files} /> <FileTree appId={app.id ?? null} files={app.files} />
</div> </div>
<div className="w-2/3"> <div className="w-2/3">
{selectedFile ? ( {selectedFile ? (
<FileEditor appId={app.id ?? null} filePath={selectedFile.path} /> <FileEditor
appId={app.id ?? null}
filePath={selectedFile.path}
initialLine={selectedFile.line ?? null}
/>
) : ( ) : (
<div className="text-center py-4 text-gray-500"> <div className="text-center py-4 text-gray-500">
Select a file to view Select a file to view
......
...@@ -20,6 +20,7 @@ import { getLanguage } from "@/utils/get_language"; ...@@ -20,6 +20,7 @@ import { getLanguage } from "@/utils/get_language";
interface FileEditorProps { interface FileEditorProps {
appId: number | null; appId: number | null;
filePath: string; filePath: string;
initialLine?: number | null;
} }
interface BreadcrumbProps { interface BreadcrumbProps {
...@@ -86,7 +87,11 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({ ...@@ -86,7 +87,11 @@ const Breadcrumb: React.FC<BreadcrumbProps> = ({
); );
}; };
export const FileEditor = ({ appId, filePath }: FileEditorProps) => { export const FileEditor = ({
appId,
filePath,
initialLine = null,
}: FileEditorProps) => {
const { content, loading, error } = useLoadAppFile(appId, filePath); const { content, loading, error } = useLoadAppFile(appId, filePath);
const { theme } = useTheme(); const { theme } = useTheme();
const [value, setValue] = useState<string | undefined>(undefined); const [value, setValue] = useState<string | undefined>(undefined);
...@@ -127,10 +132,30 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => { ...@@ -127,10 +132,30 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
window.matchMedia("(prefers-color-scheme: dark)").matches); window.matchMedia("(prefers-color-scheme: dark)").matches);
const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light"; const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light";
// Navigate to a specific line in the editor
const navigateToLine = React.useCallback((line: number | null) => {
if (line == null || !editorRef.current) {
return;
}
const lineNumber = Math.max(1, Math.floor(line));
const editor = editorRef.current;
const model = editor.getModel();
if (!model) return;
if (lineNumber > model.getLineCount()) return;
editor.revealLineInCenter(lineNumber);
editor.setPosition({ lineNumber, column: 1 });
}, []);
// Handle editor mount // Handle editor mount
const handleEditorDidMount: OnMount = (editor) => { const handleEditorDidMount: OnMount = (editor) => {
editorRef.current = editor; editorRef.current = editor;
// Navigate to initialLine if provided (handles case when editor mounts after initialLine is set)
if (initialLine != null) {
navigateToLine(initialLine);
}
// Listen for model content change events // Listen for model content change events
editor.onDidBlurEditorText(() => { editor.onDidBlurEditorText(() => {
console.log("Editor text blurred, checking if save needed"); console.log("Editor text blurred, checking if save needed");
...@@ -191,6 +216,16 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => { ...@@ -191,6 +216,16 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
} }
}; };
// Jump to target line if provided (e.g., from search results)
// This effect handles when initialLine changes after the editor is mounted
// Include content in dependencies to ensure navigation only occurs after file content is loaded
useEffect(() => {
// Only navigate if content is loaded (not null) to avoid navigating in old file content
if (content !== null) {
navigateToLine(initialLine ?? null);
}
}, [initialLine, filePath, content, navigateToLine]);
if (loading) { if (loading) {
return <div className="p-4">Loading file content...</div>; return <div className="p-4">Loading file content...</div>;
} }
......
import { IpcClient } from "@/ipc/ipc_client";
import type { AppFileSearchResult } from "@/ipc/ipc_types";
import { useQuery } from "@tanstack/react-query";
export function useSearchAppFiles(appId: number | null, query: string) {
const trimmedQuery = query.trim();
const enabled = Boolean(appId != null && trimmedQuery.length > 0);
const { data, isFetching, isLoading, error } = useQuery({
queryKey: ["search-app-files", appId, trimmedQuery],
enabled,
queryFn: async (): Promise<AppFileSearchResult[]> => {
return IpcClient.getInstance().searchAppFiles(appId!, trimmedQuery);
},
});
return {
results: data ?? [],
loading: enabled ? isFetching || isLoading : false,
error: enabled ? error : null,
};
}
...@@ -12,6 +12,7 @@ import type { ...@@ -12,6 +12,7 @@ import type {
ConsoleEntry, ConsoleEntry,
ChangeAppLocationParams, ChangeAppLocationParams,
ChangeAppLocationResult, ChangeAppLocationResult,
AppFileSearchResult,
} from "../ipc_types"; } from "../ipc_types";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
...@@ -67,6 +68,93 @@ import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils" ...@@ -67,6 +68,93 @@ import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"
import { AppSearchResult } from "@/lib/schemas"; import { AppSearchResult } from "@/lib/schemas";
import { getAppPort } from "../../../shared/ports"; import { getAppPort } from "../../../shared/ports";
import os from "node:os";
const MAX_FILE_SEARCH_SIZE = 1024 * 1024;
const RIPGREP_EXCLUDED_GLOBS = ["!node_modules/**", "!.git/**", "!.next/**"];
// Replace node_modules.asar with node_modules.asar.unpacked for Electron packaged apps
// This is necessary because native binaries are unpacked from the asar archive
function getRgExecutablePath(): string {
const isWindows = os.platform() === "win32";
const executableName = isWindows ? "rg.exe" : "rg";
if (!app.isPackaged) {
// Dev: app.getAppPath() is the project root (same pattern as dugite)
return path.join(
app.getAppPath(),
"node_modules",
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
// Packaged app: ripgrep is bundled via extraResource
// Since we extract "node_modules/@vscode/ripgrep", it's at resources/@vscode/ripgrep
return path.join(
process.resourcesPath,
"@vscode",
"ripgrep",
"bin",
executableName,
);
}
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
function sanitizeSnippetText(text: string) {
return text.replace(/\s+/g, " ").trim();
}
/**
* Converts a byte offset in UTF-8 encoded string to a character index.
* Ripgrep provides byte offsets, but JavaScript strings use character indices.
* This handles multi-byte UTF-8 characters (emojis, CJK, accented characters) correctly.
*/
function byteOffsetToCharIndex(text: string, byteOffset: number): number {
// Cap the byte offset to the actual byte length of the string
const totalBytes = Buffer.from(text, "utf8").length;
const safeByteOffset = Math.min(byteOffset, totalBytes);
// Find the character index by checking byte counts at each position
// This correctly handles multi-byte characters
for (let i = 0; i <= text.length; i++) {
const bytesUpToIndex = Buffer.from(text.slice(0, i), "utf8").length;
if (bytesUpToIndex >= safeByteOffset) {
return i;
}
}
return text.length;
}
function buildSnippetFromMatch({
lineText,
start,
end,
lineNumber,
}: {
lineText: string;
start: number;
end: number;
lineNumber: number;
}): NonNullable<AppFileSearchResult["snippets"]>[number] {
const safeLine = lineText.replace(/\r?\n$/, "");
// Convert byte offsets to character indices for proper UTF-8 handling
const startChar = byteOffsetToCharIndex(safeLine, start);
const endChar = byteOffsetToCharIndex(safeLine, end);
const before = sanitizeSnippetText(safeLine.slice(0, startChar));
const match = sanitizeSnippetText(safeLine.slice(startChar, endChar));
const after = sanitizeSnippetText(safeLine.slice(endChar));
return {
before,
match,
after,
line: lineNumber,
};
}
function getDefaultCommand(appId: number): string { function getDefaultCommand(appId: number): string {
const port = getAppPort(appId); const port = getAppPort(appId);
...@@ -95,9 +183,6 @@ async function copyDir( ...@@ -95,9 +183,6 @@ async function copyDir(
}); });
} }
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
let proxyWorker: Worker | null = null; let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able // Needed, otherwise electron in MacOS/Linux will not be able
...@@ -587,6 +672,123 @@ async function stopDockerContainersOnPort(port: number): Promise<void> { ...@@ -587,6 +672,123 @@ async function stopDockerContainersOnPort(port: number): Promise<void> {
} }
} }
async function searchAppFilesWithRipgrep({
appPath,
query,
}: {
appPath: string;
query: string;
}): Promise<AppFileSearchResult[]> {
return new Promise((resolve, reject) => {
const results = new Map<string, AppFileSearchResult>();
const args = [
"--json",
"--no-config",
"--ignore-case",
"--fixed-strings",
"--max-filesize",
`${MAX_FILE_SEARCH_SIZE}`,
...RIPGREP_EXCLUDED_GLOBS.flatMap((glob) => ["--glob", glob]),
query,
".",
];
const rg = spawn(getRgExecutablePath(), args, { cwd: appPath });
let buffer = "";
rg.stdout.on("data", (data) => {
buffer += data.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type !== "match" || !event.data) {
continue;
}
const matchPath = event.data.path?.text as string;
if (!matchPath) continue;
const absolutePath = path.isAbsolute(matchPath)
? matchPath
: path.join(appPath, matchPath);
const relativePath = normalizePath(
path.relative(appPath, absolutePath),
);
if (relativePath.startsWith("..")) {
continue; // outside app directory
}
const lineText = event.data.lines?.text as string;
const lineNumber = event.data.line_number as number;
const submatch = event.data.submatches?.[0];
if (
typeof lineText !== "string" ||
typeof lineNumber !== "number" ||
!submatch
) {
continue;
}
const snippet = buildSnippetFromMatch({
lineText,
start: submatch.start,
end: submatch.end,
lineNumber,
});
const existing = results.get(relativePath);
if (!existing) {
results.set(relativePath, {
path: relativePath,
matchesContent: true,
snippets: [snippet],
});
} else {
// Add snippet to existing result if it doesn't already exist (avoid duplicates)
if (!existing.snippets) {
existing.snippets = [];
}
// Only add if this line number isn't already in the snippets
const existingLine = existing.snippets.find(
(s) => s.line === snippet.line,
);
if (!existingLine) {
existing.snippets.push(snippet);
}
}
} catch (error) {
logger.warn("Failed to parse ripgrep output line:", line, error);
}
}
});
rg.stderr.on("data", (data) => {
const message = data.toString();
if (message.toLowerCase().includes("binary file skipped")) {
return;
}
logger.debug("ripgrep stderr:", message);
});
rg.on("close", (code) => {
// rg exits with code 1 when no matches are found; treat as success
if (code !== 0 && code !== 1) {
reject(new Error(`ripgrep exited with code ${code}`));
return;
}
resolve(Array.from(results.values()));
});
rg.on("error", (error) => {
reject(error);
});
});
}
export function registerAppHandlers() { export function registerAppHandlers() {
handle("restart-dyad", async () => { handle("restart-dyad", async () => {
app.relaunch(); app.relaunch();
...@@ -1587,6 +1789,37 @@ export function registerAppHandlers() { ...@@ -1587,6 +1789,37 @@ export function registerAppHandlers() {
}, },
); );
handle(
"search-app-files",
async (
_,
{ appId, query }: { appId: number; query: string },
): Promise<AppFileSearchResult[]> => {
const trimmedQuery = query.trim();
if (!trimmedQuery) {
return [];
}
const appRecord = await db.query.apps.findFirst({
where: eq(apps.id, appId),
});
if (!appRecord) {
throw new Error("App not found");
}
const appPath = getDyadAppPath(appRecord.path);
// Search file contents with ripgrep
const contentMatches = await searchAppFilesWithRipgrep({
appPath,
query: trimmedQuery,
});
return contentMatches;
},
);
handle( handle(
"search-app", "search-app",
async (_, searchQuery: string): Promise<AppSearchResult[]> => { async (_, searchQuery: string): Promise<AppSearchResult[]> => {
......
...@@ -36,6 +36,7 @@ import type { ...@@ -36,6 +36,7 @@ import type {
UserBudgetInfo, UserBudgetInfo,
CopyAppParams, CopyAppParams,
App, App,
AppFileSearchResult,
ComponentSelection, ComponentSelection,
AppUpgrade, AppUpgrade,
ProblemReport, ProblemReport,
...@@ -419,6 +420,22 @@ export class IpcClient { ...@@ -419,6 +420,22 @@ export class IpcClient {
} }
} }
public async searchAppFiles(
appId: number,
query: string,
): Promise<AppFileSearchResult[]> {
try {
const results = await this.ipcRenderer.invoke("search-app-files", {
appId,
query,
});
return results as AppFileSearchResult[];
} catch (error) {
showError(error);
throw error;
}
}
public async readAppFile(appId: number, filePath: string): Promise<string> { public async readAppFile(appId: number, filePath: string): Promise<string> {
return this.ipcRenderer.invoke("read-app-file", { return this.ipcRenderer.invoke("read-app-file", {
appId, appId,
......
...@@ -136,6 +136,17 @@ export interface App { ...@@ -136,6 +136,17 @@ export interface App {
resolvedPath?: string; resolvedPath?: string;
} }
export interface AppFileSearchResult {
path: string;
matchesContent: boolean;
snippets?: Array<{
before: string;
match: string;
after: string;
line: number;
}>;
}
export interface Version { export interface Version {
oid: string; oid: string;
message: string; message: string;
......
...@@ -30,6 +30,7 @@ const validInvokeChannels = [ ...@@ -30,6 +30,7 @@ const validInvokeChannels = [
"get-chat-logs", "get-chat-logs",
"list-apps", "list-apps",
"get-app", "get-app",
"search-app-files",
"get-app-env-vars", "get-app-env-vars",
"set-app-env-vars", "set-app-env-vars",
"edit-app-file", "edit-app-file",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论