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:
cache-dependency-path: package-lock.json
- name: Install node modules
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)
# do not run this on Windows (it fails and not necessary)
# Only run on shard 1 to avoid redundant execution
......@@ -120,6 +123,9 @@ jobs:
node-version: lts/*
- name: Install dependencies
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
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 = {
},
asar: true,
ignore,
extraResource: ["node_modules/dugite/git"],
extraResource: ["node_modules/dugite/git", "node_modules/@vscode"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
},
rebuildConfig: {
......
{
"name": "dyad",
"version": "0.33.0-beta.1",
"version": "0.33.0-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dyad",
"version": "0.33.0-beta.1",
"version": "0.33.0-beta.2",
"license": "MIT",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.9",
......@@ -51,6 +51,7 @@
"@types/uuid": "^10.0.0",
"@vercel/sdk": "^1.18.0",
"@vitejs/plugin-react": "^4.3.4",
"@vscode/ripgrep": "^1.17.0",
"ai": "^6.0.14",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
......@@ -8039,6 +8040,18 @@
"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": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz",
......@@ -9001,7 +9014,6 @@
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
......@@ -12590,7 +12602,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
......@@ -18352,7 +18363,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/perfect-freehand": {
......@@ -23523,7 +23533,6 @@
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
......
......@@ -128,6 +128,7 @@
"@types/uuid": "^10.0.0",
"@vercel/sdk": "^1.18.0",
"@vitejs/plugin-react": "^4.3.4",
"@vscode/ripgrep": "^1.17.0",
"ai": "^6.0.14",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
......
......@@ -3,6 +3,7 @@ import { atom } from "jotai";
export const isPreviewOpenAtom = atom(true);
export const selectedFileAtom = atom<{
path: string;
line?: number | null;
} | null>(null);
export const activeSettingsSectionAtom = atom<string | null>(
"general-settings",
......
import { FileEditor } from "./FileEditor";
import { FileTree } from "./FileTree";
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useLoadApp } from "@/hooks/useLoadApp";
import { RefreshCw, Maximize2, Minimize2 } from "lucide-react";
import { useAtomValue } from "jotai";
import { selectedFileAtom } from "@/atoms/viewAtoms";
......@@ -19,6 +20,27 @@ export interface CodeViewProps {
export const CodeView = ({ loading, app }: CodeViewProps) => {
const selectedFile = useAtomValue(selectedFileAtom);
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) {
return <div className="text-center py-4">Loading files...</div>;
......@@ -32,7 +54,9 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
if (app.files && app.files.length > 0) {
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 */}
<div className="flex items-center p-2 border-b space-x-2">
<button
......@@ -44,16 +68,28 @@ export const CodeView = ({ loading, app }: CodeViewProps) => {
<RefreshCw size={16} />
</button>
<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>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
<div className="w-1/3 overflow-auto border-r">
<FileTree files={app.files} />
<div className="w-1/3 border-r overflow-hidden flex flex-col min-h-0">
<FileTree appId={app.id ?? null} files={app.files} />
</div>
<div className="w-2/3">
{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">
Select a file to view
......
......@@ -20,6 +20,7 @@ import { getLanguage } from "@/utils/get_language";
interface FileEditorProps {
appId: number | null;
filePath: string;
initialLine?: number | null;
}
interface 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 { theme } = useTheme();
const [value, setValue] = useState<string | undefined>(undefined);
......@@ -127,10 +132,30 @@ export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
window.matchMedia("(prefers-color-scheme: dark)").matches);
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
const handleEditorDidMount: OnMount = (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
editor.onDidBlurEditorText(() => {
console.log("Editor text blurred, checking if save needed");
......@@ -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) {
return <div className="p-4">Loading file content...</div>;
}
......
import React from "react";
import { Folder, FolderOpen } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Loader2,
Search,
X,
} from "lucide-react";
import { selectedFileAtom } from "@/atoms/viewAtoms";
import { useSetAtom } from "jotai";
import { Input } from "@/components/ui/input";
import type { AppFileSearchResult } from "@/ipc/ipc_types";
import { useSearchAppFiles } from "@/hooks/useSearchAppFiles";
interface FileTreeProps {
appId: number | null;
files: string[];
}
......@@ -14,6 +26,42 @@ interface TreeNode {
children: TreeNode[];
}
const useDebouncedValue = <T,>(value: T, delay = 200) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
const highlightMatch = (text: string, query: string) => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return text;
const lowerText = text.toLowerCase();
const lowerQuery = trimmedQuery.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) {
return text;
}
const end = index + trimmedQuery.length;
return (
<>
{text.slice(0, index)}
<mark className="rounded-sm bg-primary/15 px-0.5 text-foreground">
{text.slice(index, end)}
</mark>
{text.slice(end)}
</>
);
};
// Convert flat file list to tree structure
const buildFileTree = (files: string[]): TreeNode[] => {
const root: TreeNode[] = [];
......@@ -51,12 +99,141 @@ const buildFileTree = (files: string[]): TreeNode[] => {
};
// File tree component
export const FileTree = ({ files }: FileTreeProps) => {
const treeData = buildFileTree(files);
export const FileTree = ({ appId, files }: FileTreeProps) => {
const [searchValue, setSearchValue] = useState("");
const prevAppIdRef = useRef<number | null>(appId);
// Reset search when appId changes to prevent unnecessary IPC calls with old search term
useEffect(() => {
if (prevAppIdRef.current !== appId) {
prevAppIdRef.current = appId;
setSearchValue("");
}
}, [appId]);
const debouncedSearch = useDebouncedValue(searchValue, 250);
const isSearchMode = debouncedSearch.trim().length > 0;
const {
results: searchResults,
loading: searchLoading,
error: searchError,
} = useSearchAppFiles(appId, debouncedSearch);
const matchesByPath = useMemo(() => {
const map = new Map<string, AppFileSearchResult>();
for (const result of searchResults) {
map.set(result.path, result);
}
return map;
}, [searchResults]);
const visibleFiles = useMemo(() => {
if (!isSearchMode) {
return files;
}
return files.filter((filePath) => matchesByPath.has(filePath));
}, [files, isSearchMode, matchesByPath]);
const treeData = useMemo(() => buildFileTree(visibleFiles), [visibleFiles]);
// In search mode, create a flat list of matching files with match counts
const searchResultsList = useMemo(() => {
if (!isSearchMode) {
return [];
}
return Array.from(matchesByPath.entries())
.map(([path, result]) => ({
path,
matchCount: result.snippets?.length ?? 0,
result,
}))
.sort((a, b) => {
// Sort by match count (descending), then by path (ascending)
if (b.matchCount !== a.matchCount) {
return b.matchCount - a.matchCount;
}
return a.path.localeCompare(b.path);
});
}, [isSearchMode, matchesByPath]);
return (
<div className="file-tree mt-2">
<TreeNodes nodes={treeData} level={0} />
<div className="file-tree mt-2 flex h-full flex-col">
<div className="px-2 pb-2">
<div className="relative">
<Search
size={14}
className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search file contents"
className="h-8 pl-7 pr-16 text-sm"
data-testid="file-tree-search"
disabled={!appId}
/>
{searchValue && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchValue("")}
aria-label="Clear search"
>
<X size={14} />
</button>
)}
{searchLoading && (
<Loader2
size={14}
className="absolute right-7 top-1/2 -translate-y-1/2 animate-spin text-muted-foreground"
/>
)}
</div>
{isSearchMode && (
<div className="mt-1 flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{searchLoading
? "Searching files..."
: `${matchesByPath.size} match${matchesByPath.size === 1 ? "" : "es"}`}
</span>
</div>
)}
</div>
<div className="flex-1 overflow-auto">
{isSearchMode && searchError && (
<div className="px-3 py-2 text-xs text-red-500">
{searchError.message}
</div>
)}
{isSearchMode &&
!searchLoading &&
!searchError &&
matchesByPath.size === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No files matched your search.
</div>
) : isSearchMode ? (
<div className="px-2 py-1">
{searchResultsList.map(({ path, matchCount, result }) => (
<SearchResultItem
key={path}
path={path}
matchCount={matchCount}
result={result}
/>
))}
</div>
) : (
<TreeNodes
nodes={treeData}
level={0}
matchesByPath={matchesByPath}
isSearchMode={isSearchMode}
searchQuery={debouncedSearch}
/>
)}
</div>
</div>
);
};
......@@ -64,6 +241,9 @@ export const FileTree = ({ files }: FileTreeProps) => {
interface TreeNodesProps {
nodes: TreeNode[];
level: number;
matchesByPath: Map<string, AppFileSearchResult>;
isSearchMode: boolean;
searchQuery: string;
}
// Sort nodes to show directories first
......@@ -77,10 +257,23 @@ const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
};
// Tree nodes component
const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
const TreeNodes = ({
nodes,
level,
matchesByPath,
isSearchMode,
searchQuery,
}: TreeNodesProps) => (
<ul className="ml-4">
{sortNodes(nodes).map((node, index) => (
<TreeNode key={index} node={node} level={level} />
{sortNodes(nodes).map((node) => (
<TreeNode
key={node.path}
node={node}
level={level}
matchesByPath={matchesByPath}
isSearchMode={isSearchMode}
searchQuery={searchQuery}
/>
))}
</ul>
);
......@@ -88,12 +281,108 @@ const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
interface TreeNodeProps {
node: TreeNode;
level: number;
matchesByPath: Map<string, AppFileSearchResult>;
isSearchMode: boolean;
searchQuery: string;
}
// Search result item component (flat list in search mode)
interface SearchResultItemProps {
path: string;
matchCount: number;
result: AppFileSearchResult;
}
const SearchResultItem = ({
path,
matchCount,
result,
}: SearchResultItemProps) => {
const setSelectedFile = useSetAtom(selectedFileAtom);
const [isExpanded, setIsExpanded] = useState(false);
const handleFileClick = () => {
setIsExpanded(!isExpanded);
};
const handleSnippetClick = (line: number) => {
setSelectedFile({
path,
line,
});
};
return (
<div className="py-1">
<div
className="flex items-center rounded px-1.5 py-1 text-sm hover:bg-(--sidebar) cursor-pointer"
onClick={handleFileClick}
>
{/* Chevron */}
<span className="text-muted-foreground mr-1.5 flex-shrink-0">
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
{/* Path */}
<span className="truncate flex-1">{path}</span>
{/* Count badge (right-aligned, circular) */}
<span
className="
ml-auto
flex h-5 min-w-[1.25rem] items-center justify-center
rounded-full
bg-muted
text-xs font-medium
text-muted-foreground
"
>
{matchCount}
</span>
</div>
{isExpanded &&
result.snippets &&
result.snippets.length > 0 &&
result.snippets.map((snippet, index) => (
<div
key={`${snippet.line}-${index}`}
className="ml-12 mr-2 py-0.5 text-xs cursor-pointer hover:bg-muted/50 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleSnippetClick(snippet.line);
}}
>
<div className="font-mono text-[11px] leading-tight text-foreground truncate">
<span className="text-muted-foreground">{snippet.before}</span>
<mark className="bg-primary/20 text-foreground font-medium px-0.5 rounded">
{snippet.match}
</mark>
<span className="text-muted-foreground">{snippet.after}</span>
</div>
</div>
))}
</div>
);
};
// Individual tree node component
const TreeNode = ({ node, level }: TreeNodeProps) => {
const [expanded, setExpanded] = React.useState(level < 2);
const TreeNode = ({
node,
level,
matchesByPath,
isSearchMode,
searchQuery,
}: TreeNodeProps) => {
const [expanded, setExpanded] = useState(level < 2);
const setSelectedFile = useSetAtom(selectedFileAtom);
const match = isSearchMode ? matchesByPath.get(node.path) : undefined;
useEffect(() => {
if (isSearchMode && node.isDirectory) {
setExpanded(true);
}
}, [isSearchMode, node.isDirectory]);
const handleClick = () => {
if (node.isDirectory) {
......@@ -101,6 +390,7 @@ const TreeNode = ({ node, level }: TreeNodeProps) => {
} else {
setSelectedFile({
path: node.path,
line: match?.snippets?.[0]?.line ?? null,
});
}
};
......@@ -108,7 +398,7 @@ const TreeNode = ({ node, level }: TreeNodeProps) => {
return (
<li className="py-0.5">
<div
className="flex items-center hover:bg-(--sidebar) rounded cursor-pointer px-1.5 py-0.5 text-sm"
className="flex items-center rounded px-1.5 py-0.5 text-sm hover:bg-(--sidebar)"
onClick={handleClick}
>
{node.isDirectory && (
......@@ -116,11 +406,44 @@ const TreeNode = ({ node, level }: TreeNodeProps) => {
{expanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</span>
)}
<span>{node.name}</span>
<span className="truncate flex-1">
{isSearchMode ? highlightMatch(node.name, searchQuery) : node.name}
</span>
</div>
{match?.matchesContent &&
match.snippets &&
match.snippets.length > 0 &&
match.snippets.map((snippet, index) => (
<div
key={`${snippet.line}-${index}`}
className="ml-6 mr-2 py-0.5 text-xs cursor-pointer hover:bg-muted/50 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedFile({
path: node.path,
line: snippet.line,
});
}}
>
<div className="font-mono text-[11px] leading-tight text-foreground truncate">
<span className="text-muted-foreground">{snippet.before}</span>
<mark className="bg-primary/20 text-foreground font-medium px-0.5 rounded">
{snippet.match}
</mark>
<span className="text-muted-foreground">{snippet.after}</span>
</div>
</div>
))}
{node.isDirectory && expanded && node.children.length > 0 && (
<TreeNodes nodes={node.children} level={level + 1} />
<TreeNodes
nodes={node.children}
level={level + 1}
matchesByPath={matchesByPath}
isSearchMode={isSearchMode}
searchQuery={searchQuery}
/>
)}
</li>
);
......
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 {
ConsoleEntry,
ChangeAppLocationParams,
ChangeAppLocationResult,
AppFileSearchResult,
} from "../ipc_types";
import fs from "node:fs";
import path from "node:path";
......@@ -67,6 +68,93 @@ import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"
import { AppSearchResult } from "@/lib/schemas";
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 {
const port = getAppPort(appId);
......@@ -95,9 +183,6 @@ async function copyDir(
});
}
const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger);
let proxyWorker: Worker | null = null;
// Needed, otherwise electron in MacOS/Linux will not be able
......@@ -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() {
handle("restart-dyad", async () => {
app.relaunch();
......@@ -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(
"search-app",
async (_, searchQuery: string): Promise<AppSearchResult[]> => {
......
......@@ -36,6 +36,7 @@ import type {
UserBudgetInfo,
CopyAppParams,
App,
AppFileSearchResult,
ComponentSelection,
AppUpgrade,
ProblemReport,
......@@ -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> {
return this.ipcRenderer.invoke("read-app-file", {
appId,
......
......@@ -136,6 +136,17 @@ export interface App {
resolvedPath?: string;
}
export interface AppFileSearchResult {
path: string;
matchesContent: boolean;
snippets?: Array<{
before: string;
match: string;
after: string;
line: number;
}>;
}
export interface Version {
oid: string;
message: string;
......
......@@ -30,6 +30,7 @@ const validInvokeChannels = [
"get-chat-logs",
"list-apps",
"get-app",
"search-app-files",
"get-app-env-vars",
"set-app-env-vars",
"edit-app-file",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论