diff --git a/e2e/search-dnd.spec.ts b/e2e/search-dnd.spec.ts new file mode 100644 index 00000000..ba8928a1 --- /dev/null +++ b/e2e/search-dnd.spec.ts @@ -0,0 +1,97 @@ +// E2E: drag & drop from Search results. Uses synthetic HTML5 DragEvents with a +// shared DataTransfer (the only way to exercise the application/cate-file MIME +// payload — Playwright's mouse drag produces an empty dataTransfer). Dispatches +// dragstart on the real Search row (so SearchResultsTree populates the payload) +// then drop on the target, exercising the full source→target chain. + +import { test, expect, type Page } from '@playwright/test' +import path from 'node:path' +import { launchApp, closeApp, type LaunchResult } from './fixtures/electron-app' + +const REPO_ROOT = path.resolve(__dirname, '..') + +async function openSearch(page: Page) { + await page.evaluate((root) => window.__cateE2E!.setWorkspaceRoot(root), REPO_ROOT) + await page.evaluate(() => window.__cateE2E!.openSidebarView('search')) + const input = page.locator('input[aria-label="Search"]') + await input.waitFor({ state: 'visible', timeout: 10_000 }) + await input.fill('registerSearchHandlers') + await expect.poll( + async () => page.evaluate(() => window.__cateE2E!.getSearchSnapshot().status), + { timeout: 12_000 }, + ).toBe('done') +} + +/** Dispatch dragstart on a Search row, then drop it on a target selector. */ +async function dragRowToTarget(page: Page, rowTestId: string, targetSelector: string) { + await page.evaluate( + ({ rowTestId, targetSelector }) => { + const row = document.querySelector(`[data-testid="${rowTestId}"]`) + const target = document.querySelector(targetSelector) + if (!row || !target) throw new Error(`missing row(${rowTestId}) or target(${targetSelector})`) + const dt = new DataTransfer() + row.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })) + const rect = target.getBoundingClientRect() + const opts: DragEventInit = { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + } + target.dispatchEvent(new DragEvent('dragenter', opts)) + target.dispatchEvent(new DragEvent('dragover', opts)) + target.dispatchEvent(new DragEvent('drop', opts)) + }, + { rowTestId, targetSelector }, + ) +} + +test.describe('search drag & drop', () => { + let app: LaunchResult + test.beforeEach(async () => { + app = await launchApp() + }) + test.afterEach(async () => { + await closeApp(app.electronApp) + }) + + test('dragging a file result onto the canvas opens a floating editor', async () => { + const page = app.mainWindow + await openSearch(page) + const before = await page.evaluate(() => window.__cateE2E!.nodes().length) + + await dragRowToTarget(page, 'search-file', '[data-canvas-panel-id]') + + await expect + .poll(async () => page.evaluate(() => window.__cateE2E!.nodes().length), { timeout: 10_000 }) + .toBeGreaterThan(before) + }) + + test('dragging a match line onto the canvas opens it at that line', async () => { + const page = app.mainWindow + await openSearch(page) + const lineNo = Number( + await page.locator('[data-testid="search-line"]').first().getAttribute('data-line'), + ) + expect(lineNo).toBeGreaterThan(0) + + await dragRowToTarget(page, 'search-line', '[data-canvas-panel-id]') + + await expect + .poll(async () => page.evaluate(() => window.__cateE2E!.lastEditorReveal()?.line ?? 0), { timeout: 10_000 }) + .toBe(lineNo) + }) + + test('dragging a file result onto the dock center zone opens an editor tab', async () => { + const page = app.mainWindow + await openSearch(page) + const before = await page.evaluate(() => window.__cateE2E!.editorPaths().length) + + await dragRowToTarget(page, 'search-file', '[data-dock-zone="center"]') + + await expect + .poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 }) + .toBeGreaterThan(before) + }) +}) diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts new file mode 100644 index 00000000..a73f7674 --- /dev/null +++ b/e2e/search.spec.ts @@ -0,0 +1,255 @@ +// E2E: the VS Code-style content Search view, end-to-end against the real +// ripgrep engine. Points the workspace at the repo, opens the Search view, and +// exercises query, match options, filters, dismissal, keyboard nav, and +// open-at-match. + +import { test, expect, type Page } from '@playwright/test' +import path from 'node:path' +import { launchApp, closeApp, type LaunchResult } from './fixtures/electron-app' + +const REPO_ROOT = path.resolve(__dirname, '..') + +type Snapshot = { + query: string + isRegex: boolean + matchCase: boolean + wholeWord: boolean + respectIgnore: boolean + optionsExpanded: boolean + status: string + searchId: string | null + error: string | null + fileCount: number + filePaths: string[] + totalMatches: number + dismissedFiles: number + dismissedLines: number +} + +const snap = (page: Page): Promise => + page.evaluate(() => window.__cateE2E!.getSearchSnapshot() as unknown) as Promise + +/** Open the Search view rooted at the repo; returns the query input locator. */ +async function openSearch(page: Page) { + await page.evaluate((root) => window.__cateE2E!.setWorkspaceRoot(root), REPO_ROOT) + await page.evaluate(() => window.__cateE2E!.openSidebarView('search')) + const input = page.locator('input[aria-label="Search"]') + await input.waitFor({ state: 'visible', timeout: 10_000 }) + return input +} + +/** Wait until a NEW search (id != prior) has settled. Avoids reading stale + * results from a previous search that is still in the 'done' state. */ +async function settle(page: Page, priorSearchId: string | null) { + await expect + .poll( + async () => { + const s = await snap(page) + return s.searchId !== priorSearchId && s.status === 'done' + }, + { timeout: 12_000 }, + ) + .toBe(true) +} + +/** Run the first search for `query` and wait for it to settle. */ +async function search(page: Page, input: ReturnType, query: string) { + const prior = (await snap(page)).searchId + await input.fill(query) + await settle(page, prior) +} + +test.describe('content search', () => { + let app: LaunchResult + + test.beforeEach(async () => { + app = await launchApp() + }) + test.afterEach(async () => { + await closeApp(app.electronApp) + }) + + test('searches the repo, highlights matches, and opens a result', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await input.fill('registerSearchHandlers') + + await expect(page.getByText(/results in .* files?/i)).toBeVisible({ timeout: 10_000 }) + const mark = page.locator('mark', { hasText: 'registerSearchHandlers' }).first() + await expect(mark).toBeVisible({ timeout: 10_000 }) + + await mark.click() + await expect + .poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 }) + .toBeGreaterThan(0) + }) + + test('shows "No results" for a query that matches nothing', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await input.fill('zzz_no_such_token_qwerty_12345') + await expect(page.getByText('No results')).toBeVisible({ timeout: 10_000 }) + }) + + test('regex toggle changes literal vs pattern matching', async () => { + const page = app.mainWindow + const input = await openSearch(page) + // Built at runtime so the contiguous literal isn't in this source file + // (otherwise ripgrep would self-match it during the literal search). + const pattern = ['useState', 'useEffect'].join('|') + await search(page, input, pattern) // literal — no such contiguous text + expect((await snap(page)).totalMatches).toBe(0) + + const prior = (await snap(page)).searchId + await page.locator('button[aria-label="Use Regular Expression"]').click() + await settle(page, prior) + const s = await snap(page) + expect(s.isRegex).toBe(true) + expect(s.totalMatches).toBeGreaterThan(0) // now matches as an alternation + }) + + test('invalid regex surfaces an inline error', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await page.locator('button[aria-label="Use Regular Expression"]').click() + await search(page, input, '(unclosed') + expect((await snap(page)).error).toBeTruthy() + await expect(page.locator('.text-red-400')).toBeVisible({ timeout: 5_000 }) + }) + + test('whole-word toggle narrows matches', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'use') + const loose = (await snap(page)).totalMatches + expect(loose).toBeGreaterThan(0) + + const prior = (await snap(page)).searchId + await page.locator('button[aria-label="Match Whole Word"]').click() + await settle(page, prior) + const s = await snap(page) + expect(s.wholeWord).toBe(true) + expect(s.totalMatches).toBeLessThan(loose) // "useState" etc. no longer match + }) + + test('match-case toggle flips state and re-runs', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'usestate') // lowercase + const loose = (await snap(page)).totalMatches + + const prior = (await snap(page)).searchId + await page.locator('button[aria-label="Match Case"]').click() + await settle(page, prior) + const s = await snap(page) + expect(s.matchCase).toBe(true) + expect(s.totalMatches).toBeLessThanOrEqual(loose) // fewer/zero exact-case hits + }) + + test('files-to-include glob restricts results', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'useState') + + await page.locator('button[aria-label="Toggle search details"]').click() + const prior = (await snap(page)).searchId + await page.locator('input[aria-label="files to include"]').fill('*.tsx') + await settle(page, prior) + const s = await snap(page) + expect(s.fileCount).toBeGreaterThan(0) + expect(s.filePaths.every((p) => p.endsWith('.tsx'))).toBe(true) + }) + + test('files-to-exclude glob removes results', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'useState') + expect((await snap(page)).filePaths.some((p) => p.endsWith('.tsx'))).toBe(true) + + await page.locator('button[aria-label="Toggle search details"]').click() + const prior = (await snap(page)).searchId + await page.locator('input[aria-label="files to exclude"]').fill('*.tsx') + await settle(page, prior) + expect((await snap(page)).filePaths.some((p) => p.endsWith('.tsx'))).toBe(false) + }) + + test('"use ignore files" gear toggle flips and re-runs', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'registerSearchHandlers') + expect((await snap(page)).respectIgnore).toBe(true) + + await page.locator('button[aria-label="Toggle search details"]').click() + const prior = (await snap(page)).searchId + await page.locator('button[aria-label="Use Exclude Settings and Ignore Files"]').click() + await settle(page, prior) + const s = await snap(page) + expect(s.respectIgnore).toBe(false) + expect(s.error).toBeNull() + }) + + test('dismissing a match decrements the count', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'registerSearchHandlers') + expect((await snap(page)).totalMatches).toBeGreaterThan(0) + + const line = page.locator('[data-testid="search-line"]').first() + await line.hover() + await line.locator('button[title="Dismiss match"]').click() + await expect.poll(async () => (await snap(page)).dismissedLines).toBe(1) + }) + + test('dismissing a file removes it from results', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'registerSearchHandlers') + + const file = page.locator('[data-testid="search-file"]').first() + await file.hover() + await file.locator('button[title="Dismiss file"]').click() + await expect.poll(async () => (await snap(page)).dismissedFiles).toBe(1) + }) + + test('keyboard: ArrowDown + Enter opens the focused match', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'registerSearchHandlers') + + const tree = page.locator('[data-testid="search-results"]') + await tree.press('ArrowDown') // file row (0) → first match line (1) + // Selection must move to a match line (proves the list owns its arrow keys). + await expect(page.locator('[data-selected="true"]')).toHaveAttribute('data-testid', 'search-line') + await tree.press('Enter') + await expect + .poll(async () => page.evaluate(() => window.__cateE2E!.editorPaths().length), { timeout: 10_000 }) + .toBeGreaterThan(0) + }) + + test('clicking a match opens the editor at that line', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'registerSearchHandlers') + + const line = page.locator('[data-testid="search-line"]').first() + const lineNo = Number(await line.getAttribute('data-line')) + expect(lineNo).toBeGreaterThan(0) + await line.click() + + const reveal = await page.evaluate(() => window.__cateE2E!.lastEditorReveal()) + expect(reveal?.line).toBe(lineNo) + }) + + test('clear button resets the query and results', async () => { + const page = app.mainWindow + const input = await openSearch(page) + await search(page, input, 'useState') + expect((await snap(page)).fileCount).toBeGreaterThan(0) + + await page.locator('button[aria-label="Clear search"]').click() + await expect.poll(async () => (await snap(page)).query).toBe('') + const s = await snap(page) + expect(s.fileCount).toBe(0) + expect(s.status).toBe('idle') + }) +}) diff --git a/electron-builder.yml b/electron-builder.yml index 13a9eaf2..53223f2c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -17,6 +17,10 @@ asarUnpack: - "node_modules/@earendil-works/**" - "node_modules/.bin/pi" - "node_modules/.bin/pi.cmd" + # ripgrep binary for the Search view. The wrapper resolves to a per-platform + # package (@vscode/ripgrep--); unpack both so rgPath is a real file. + - "node_modules/@vscode/ripgrep/**" + - "node_modules/@vscode/ripgrep-*/**" - "node_modules/@anthropic-ai/sdk/**" - "node_modules/@aws-crypto/crc32/**" - "node_modules/@aws-crypto/sha256-browser/**" diff --git a/package-lock.json b/package-lock.json index 46e219e4..cd3d572e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@earendil-works/pi-coding-agent": "^0.75.4", "@phosphor-icons/react": "^2.1.10", "@sentry/electron": "^5.11.0", + "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.11.0", @@ -6690,6 +6691,182 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vscode/ripgrep": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", + "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==", + "license": "MIT", + "optionalDependencies": { + "@vscode/ripgrep-darwin-arm64": "1.18.0", + "@vscode/ripgrep-darwin-x64": "1.18.0", + "@vscode/ripgrep-linux-arm": "1.18.0", + "@vscode/ripgrep-linux-arm64": "1.18.0", + "@vscode/ripgrep-linux-ia32": "1.18.0", + "@vscode/ripgrep-linux-ppc64": "1.18.0", + "@vscode/ripgrep-linux-riscv64": "1.18.0", + "@vscode/ripgrep-linux-s390x": "1.18.0", + "@vscode/ripgrep-linux-x64": "1.18.0", + "@vscode/ripgrep-win32-arm64": "1.18.0", + "@vscode/ripgrep-win32-ia32": "1.18.0", + "@vscode/ripgrep-win32-x64": "1.18.0" + } + }, + "node_modules/@vscode/ripgrep-darwin-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz", + "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-darwin-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz", + "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz", + "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz", + "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz", + "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ppc64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz", + "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-riscv64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz", + "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-s390x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz", + "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz", + "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-win32-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz", + "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz", + "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", + "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", diff --git a/package.json b/package.json index d4275f65..95db7459 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@earendil-works/pi-coding-agent": "^0.75.4", "@phosphor-icons/react": "^2.1.10", "@sentry/electron": "^5.11.0", + "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-web-links": "^0.11.0", diff --git a/scripts/patch-electron-name.sh b/scripts/patch-electron-name.sh index 81fe60dd..9fa1633b 100755 --- a/scripts/patch-electron-name.sh +++ b/scripts/patch-electron-name.sh @@ -3,6 +3,8 @@ # Restore exec bit on node-pty's spawn-helper — npm sometimes strips it on # extraction, causing posix_spawnp to fail at runtime. chmod +x node_modules/node-pty/prebuilds/*/spawn-helper 2>/dev/null || true +# Same for the bundled ripgrep binary used by the Search view. +chmod +x node_modules/@vscode/ripgrep*/bin/rg 2>/dev/null || true PLIST="node_modules/electron/dist/Electron.app/Contents/Info.plist" if [ -f "$PLIST" ]; then diff --git a/src/agent/renderer/AgentPanel.tsx b/src/agent/renderer/AgentPanel.tsx index 23d4973f..ffc39b72 100644 --- a/src/agent/renderer/AgentPanel.tsx +++ b/src/agent/renderer/AgentPanel.tsx @@ -31,6 +31,7 @@ import type { PanelProps } from '../../renderer/panels/types' import { useAppStore } from '../../renderer/stores/appStore' import { useStatusStore } from '../../renderer/stores/statusStore' import { useAgentStore } from './agentStore' +import { buildFileMentions, type LineRef } from './agentDrop' import { ChatThread } from './ChatThread' import { AgentSidebar } from './AgentSidebar' import { ChatInput } from './AgentChatInput' @@ -772,6 +773,18 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { if (any) e.preventDefault() }, [handleAddImage]) + // Whole-panel file drop. The drop indicator is rendered globally by + // (the root is marked data-filedrop="agent"); the chat + // input also forwards drops here and handleDrop stops propagation so a drop + // never fires twice. + const handlePanelDragOver = useCallback((e: React.DragEvent) => { + const t = e.dataTransfer?.types + if (t && (t.includes('application/cate-files') || t.includes('application/cate-file') || t.includes('Files'))) { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + } + }, []) + const handleDrop = useCallback(async (e: React.DragEvent) => { // Files dragged from Cate's own Explorer come through as a JSON payload of // absolute paths under `application/cate-files`. Insert them into the draft @@ -779,10 +792,18 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { const cateRaw = e.dataTransfer?.getData('application/cate-files') if (cateRaw) { e.preventDefault() + e.stopPropagation() try { const paths = JSON.parse(cateRaw) as string[] if (Array.isArray(paths) && paths.length > 0) { - const mentions = paths.map((p) => `@${p}`).join(' ') + // A search-line drag carries the line number — mention it as + // @path:line so the agent gets the exact location. + let lineRef: LineRef | null = null + const lineRaw = e.dataTransfer.getData('application/cate-file-line') + if (lineRaw) { + try { lineRef = JSON.parse(lineRaw) } catch { /* ignore */ } + } + const mentions = buildFileMentions(paths, lineRef) setDraft((prev) => (prev ? `${prev}${prev.endsWith(' ') ? '' : ' '}${mentions} ` : `${mentions} `)) } } catch { /* ignore malformed payload */ } @@ -790,6 +811,7 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { } if (!e.dataTransfer?.files?.length) return e.preventDefault() + e.stopPropagation() for (const file of Array.from(e.dataTransfer.files)) { const img = await readFileAsImage(file) if (img) handleAddImage(img) @@ -801,7 +823,12 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { // --------------------------------------------------------------------------- return ( -
+
{sidebarOpen && ( { + it('builds @path mentions for plain file drags', () => { + expect(buildFileMentions(['/a.ts', '/b.ts'], null)).toBe('@/a.ts @/b.ts') + }) + + it('appends :line for the matching search-line drag', () => { + expect(buildFileMentions(['/a.ts'], { path: '/a.ts', line: 42 })).toBe('@/a.ts:42') + }) + + it('only annotates the path that matches the line ref', () => { + expect(buildFileMentions(['/a.ts', '/b.ts'], { path: '/b.ts', line: 7 })).toBe('@/a.ts @/b.ts:7') + }) + + it('ignores a line ref with no line number', () => { + expect(buildFileMentions(['/a.ts'], { path: '/a.ts' })).toBe('@/a.ts') + }) +}) diff --git a/src/agent/renderer/agentDrop.ts b/src/agent/renderer/agentDrop.ts new file mode 100644 index 00000000..9642e318 --- /dev/null +++ b/src/agent/renderer/agentDrop.ts @@ -0,0 +1,16 @@ +// ============================================================================= +// agentDrop — pure helper to format dropped file paths as chat @-mentions. +// A search-line drag carries a line number, mentioned as @path:line. +// Unit-testable. +// ============================================================================= + +export interface LineRef { + path?: string + line?: number +} + +export function buildFileMentions(paths: string[], lineRef: LineRef | null): string { + return paths + .map((p) => (lineRef?.path === p && lineRef.line ? `@${p}:${lineRef.line}` : `@${p}`)) + .join(' ') +} diff --git a/src/main/analytics.ts b/src/main/analytics.ts index 98104128..2e7f273a 100644 --- a/src/main/analytics.ts +++ b/src/main/analytics.ts @@ -345,6 +345,11 @@ export function decideUpdateAction(current: string, state: AnalyticsState): Upda * actual behavior matrix. */ export async function checkAndReportUpdate(mainWin: BrowserWindow): Promise { + // E2E profiles start from a fresh version state every run, which looks like a + // first install / version bump and would pop the post-update feedback modal. + // That modal intercepts pointer events and flakes tests — never show it here. + if (process.env.CATE_E2E === '1') return + if (process.env.DEV_FORCE_DIALOG) { promptFeedback(mainWin, app.getVersion(), '0.0.0') return diff --git a/src/main/index.ts b/src/main/index.ts index 718547c8..c8d99dc5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,7 @@ import { import { registerHandlers as registerTerminalHandlers, flushAllLoggers, killAllTerminals, terminalPids } from './ipc/terminal' import { registerHandlers as registerFilesystemHandlers, stopWatchersForWindow } from './ipc/filesystem' import { registerHandlers as registerGitHandlers } from './ipc/git' +import { registerHandlers as registerSearchHandlers, stopSearchesForWindow } from './ipc/search' import { registerHandlers as registerShellHandlers, unregisterTerminalsForWindow } from './ipc/shell' import { registerHandlers as registerGitMonitorHandlers, stopMonitorsForWindow } from './ipc/git-monitor' import { registerHandlers as registerStoreHandlers, loadSettingsSyncFromDisk, getSettingSync, readBootSnapshot, writeBootSnapshot } from './store' @@ -272,6 +273,7 @@ function createWindow(params?: CateWindowParams): BrowserWindow { stopWatchersForWindow(windowId) unregisterTerminalsForWindow(windowId) stopMonitorsForWindow(windowId) + stopSearchesForWindow(windowId) clearScopedWriteAllowancesForWindow(windowId) clearFileGrantsForWindow(windowId) // Rebuild menu to update panel/dock window list @@ -487,6 +489,7 @@ function registerCriticalHandlers(): void { */ function registerDeferredHandlers(): void { registerGitHandlers() + registerSearchHandlers() registerGitMonitorHandlers() registerNotificationHandlers() registerAuthHandlers(authManager) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index ed5455ba..73bf04ef 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -30,7 +30,7 @@ import { getSettingSync } from '../store' // Read the user-configured exclusion list live so changes take effect without // a relaunch. Built into a Set per call for fast membership checks. -function currentExclusionSet(): Set { +export function currentExclusionSet(): Set { return new Set(getSettingSync('fileExclusions')) } diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts new file mode 100644 index 00000000..e4201f9f --- /dev/null +++ b/src/main/ipc/search.ts @@ -0,0 +1,104 @@ +// ============================================================================= +// Search IPC — bridges the ripgrep engine to the renderer. +// +// SEARCH_START (invoke) -> returns a searchId; streams results to the sender +// SEARCH_CANCEL (invoke) -> cancels the sender's in-flight search +// SEARCH_RESULT (send) -> { searchId, files } batches +// SEARCH_DONE (send) -> { searchId, stats, error? } +// +// One search per sender webContents: starting a new one cancels the previous. +// ============================================================================= + +import { ipcMain } from 'electron' +import log from '../logger' +import { SEARCH_START, SEARCH_CANCEL, SEARCH_RESULT, SEARCH_DONE } from '../../shared/ipc-channels' +import type { SearchOptions } from '../../shared/types' +import { runSearch, type SearchHandle } from '../search/searchEngine' +import { validatePathStrict } from './pathValidation' +import { currentExclusionSet } from './filesystem' + +function clampInt(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback + return Math.max(min, Math.min(max, n)) +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] +} + +/** Coerce untrusted renderer input into a safe SearchOptions. */ +function sanitize(raw: Partial | undefined): SearchOptions { + return { + query: String(raw?.query ?? ''), + isRegex: !!raw?.isRegex, + matchCase: !!raw?.matchCase, + wholeWord: !!raw?.wholeWord, + includes: stringArray(raw?.includes), + excludes: stringArray(raw?.excludes), + respectIgnore: raw?.respectIgnore !== false, + maxResults: clampInt(raw?.maxResults, 2000, 1, 20000), + } +} + +// One active search per window (keyed by webContents id), so it can be +// cancelled when superseded or when the window is destroyed. +const active = new Map() + +/** Cancel any in-flight search for a destroyed/closed window. */ +export function stopSearchesForWindow(windowId: number): void { + active.get(windowId)?.cancel() + active.delete(windowId) +} + +export function registerHandlers(): void { + ipcMain.handle( + SEARCH_START, + async (event, rootPath: string, searchIdRaw: string, optsRaw: Partial): Promise => { + const wc = event.sender + const wcId = wc.id + // Clamp the renderer-supplied correlation id defensively. + const searchId = (typeof searchIdRaw === 'string' ? searchIdRaw : '').slice(0, 128) + + // A new search supersedes any previous one for this window. + active.get(wcId)?.cancel() + active.delete(wcId) + + const opts = sanitize(optsRaw) + if (!opts.query.trim()) return searchId + + let validRoot: string + try { + validRoot = await validatePathStrict(rootPath, wcId) + } catch (err) { + log.warn(`[${SEARCH_START}] invalid root:`, err) + if (!wc.isDestroyed()) { + wc.send(SEARCH_DONE, { + searchId, + stats: { matches: 0, files: 0, truncated: false }, + error: 'Invalid search path', + }) + } + return searchId + } + + let handle: SearchHandle | undefined + handle = runSearch(opts, validRoot, [...currentExclusionSet()], { + onBatch: (files) => { + if (!wc.isDestroyed()) wc.send(SEARCH_RESULT, { searchId, files }) + }, + onDone: (stats, error) => { + if (!wc.isDestroyed()) wc.send(SEARCH_DONE, { searchId, stats, error }) + if (handle && active.get(wcId) === handle) active.delete(wcId) + }, + }) + active.set(wcId, handle) + return searchId + }, + ) + + ipcMain.handle(SEARCH_CANCEL, (event): void => { + const wcId = event.sender.id + active.get(wcId)?.cancel() + active.delete(wcId) + }) +} diff --git a/src/main/search/ripgrepArgs.test.ts b/src/main/search/ripgrepArgs.test.ts new file mode 100644 index 00000000..1cc7ffe7 --- /dev/null +++ b/src/main/search/ripgrepArgs.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { buildRipgrepArgs } from './ripgrepArgs' +import type { SearchOptions } from '../../shared/types' + +const base = (over: Partial = {}): SearchOptions => ({ query: 'foo', ...over }) + +describe('buildRipgrepArgs', () => { + it('always emits --json and --line-number', () => { + const args = buildRipgrepArgs(base(), '/root') + expect(args).toContain('--json') + expect(args).toContain('--line-number') + }) + + it('is case-insensitive by default, case-sensitive when matchCase', () => { + expect(buildRipgrepArgs(base(), '/root')).toContain('--ignore-case') + const cs = buildRipgrepArgs(base({ matchCase: true }), '/root') + expect(cs).toContain('--case-sensitive') + expect(cs).not.toContain('--ignore-case') + }) + + it('adds --word-regexp only when wholeWord', () => { + expect(buildRipgrepArgs(base(), '/root')).not.toContain('--word-regexp') + expect(buildRipgrepArgs(base({ wholeWord: true }), '/root')).toContain('--word-regexp') + }) + + it('uses --fixed-strings for literal search and drops it for regex', () => { + expect(buildRipgrepArgs(base(), '/root')).toContain('--fixed-strings') + expect(buildRipgrepArgs(base({ isRegex: true }), '/root')).not.toContain('--fixed-strings') + }) + + it('respects ignore files by default and applies the project exclusion set', () => { + const args = buildRipgrepArgs(base(), '/root', ['node_modules', '.git']) + expect(args).not.toContain('--no-ignore') + expect(args).not.toContain('--hidden') + const globs = args.filter((_, i) => args[i - 1] === '--glob') + expect(globs).toContain('!node_modules') + expect(globs).toContain('!.git') + }) + + it('with respectIgnore=false adds --no-ignore --hidden and drops the project exclusion set', () => { + const args = buildRipgrepArgs( + base({ respectIgnore: false, excludes: ['*.lock'] }), + '/root', + ['node_modules', '.git'], + ) + expect(args).toContain('--no-ignore') + expect(args).toContain('--hidden') + const globs = args.filter((_, i) => args[i - 1] === '--glob') + expect(globs).toContain('!*.lock') // user excludes still apply + expect(globs).not.toContain('!node_modules') // project exclusion set dropped + }) + + it('maps includes to globs and excludes to negated globs', () => { + const args = buildRipgrepArgs( + base({ includes: ['src/**', '*.ts'], excludes: ['*.lock'] }), + '/root', + ['node_modules', '.git'], + ) + const globs = args.filter((_, i) => args[i - 1] === '--glob') + expect(globs).toContain('src/**') + expect(globs).toContain('*.ts') + expect(globs).toContain('!*.lock') + expect(globs).toContain('!node_modules') + expect(globs).toContain('!.git') + }) + + it('ignores blank include/exclude entries (only the always-on !.git remains)', () => { + const args = buildRipgrepArgs(base({ includes: [' ', ''], excludes: [' '] }), '/root') + const globs = args.filter((_, i) => args[i - 1] === '--glob') + expect(globs).toEqual(['!.git']) + }) + + it('always excludes the .git directory', () => { + const globs = buildRipgrepArgs(base({ respectIgnore: false }), '/root').filter( + (_, i, a) => a[i - 1] === '--glob', + ) + expect(globs).toContain('!.git') + }) + + it('passes the query via -e and the root path as the final argument', () => { + const args = buildRipgrepArgs(base({ query: '-flag-like' }), '/my/root') + expect(args[args.indexOf('-e') + 1]).toBe('-flag-like') + expect(args[args.length - 1]).toBe('/my/root') + }) +}) diff --git a/src/main/search/ripgrepArgs.ts b/src/main/search/ripgrepArgs.ts new file mode 100644 index 00000000..6c235587 --- /dev/null +++ b/src/main/search/ripgrepArgs.ts @@ -0,0 +1,73 @@ +// ============================================================================= +// ripgrepArgs — pure builder that turns a SearchOptions into ripgrep CLI args. +// +// Kept side-effect free so it can be unit-tested without spawning anything. +// ============================================================================= + +import type { SearchOptions } from '../../shared/types' + +/** + * Build the ripgrep argument vector for a content search. + * + * @param opts the user's search options + * @param rootPath directory to search (passed as the final positional arg) + * @param extraExcludes project-level directory/file names to exclude (parity + * with the Explorer exclusion set); applied only when + * respectIgnore is not disabled. Each becomes a negated glob. + */ +export function buildRipgrepArgs( + opts: SearchOptions, + rootPath: string, + extraExcludes: string[] = [], +): string[] { + const args: string[] = [ + '--json', // structured, streamable output + '--line-number', // include 1-based line numbers + ] + + // Case sensitivity. VS Code defaults to case-insensitive unless "Match Case". + args.push(opts.matchCase ? '--case-sensitive' : '--ignore-case') + + // Whole-word matching. + if (opts.wholeWord) args.push('--word-regexp') + + // Literal vs regex. ripgrep is regex by default; --fixed-strings makes the + // pattern literal. + if (!opts.isRegex) args.push('--fixed-strings') + + // Ignore handling. respectIgnore defaults to true. When explicitly false, + // search ignored + hidden files too (VS Code's gear toggle turned off) and + // skip the project exclusion set. + const respectIgnore = opts.respectIgnore !== false + if (!respectIgnore) { + args.push('--no-ignore', '--hidden') + } + + // Include globs (whitelist). A glob without a slash matches at any depth, + // matching VS Code's "files to include" behaviour. + for (const raw of opts.includes ?? []) { + const g = raw.trim() + if (g) args.push('--glob', g) + } + + // Exclude globs — user-provided always apply; project-level excludes only + // when respecting ignore files. + for (const raw of opts.excludes ?? []) { + const g = raw.trim() + if (g) args.push('--glob', `!${g}`) + } + if (respectIgnore) { + for (const name of extraExcludes) { + if (name) args.push('--glob', `!${name}`) + } + } + // Always skip the VCS internals dir — never useful to search and, with + // --hidden, ripgrep would otherwise descend into it. + args.push('--glob', '!.git') + + // Pattern via -e so a query starting with "-" is never mistaken for a flag, + // then the search root as the only positional path. + args.push('-e', opts.query, rootPath) + + return args +} diff --git a/src/main/search/ripgrepParser.test.ts b/src/main/search/ripgrepParser.test.ts new file mode 100644 index 00000000..4530d41c --- /dev/null +++ b/src/main/search/ripgrepParser.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest' +import { parseEvent, groupEvents, byteOffsetToCharOffset, type RgEvent } from './ripgrepParser' + +const begin = (p: string): RgEvent => ({ type: 'begin', data: { path: { text: p } } }) +const end = (p: string): RgEvent => ({ type: 'end', data: { path: { text: p } } }) +const match = ( + p: string, + lineNumber: number, + text: string, + submatches: { start: number; end: number }[], +): RgEvent => ({ + type: 'match', + data: { + path: { text: p }, + lines: { text: text + '\n' }, + line_number: lineNumber, + submatches: submatches.map((s) => ({ match: { text: text.slice(s.start, s.end) }, ...s })), + }, +}) +const context = (p: string, lineNumber: number, text: string): RgEvent => ({ + type: 'context', + data: { path: { text: p }, lines: { text: text + '\n' }, line_number: lineNumber, submatches: [] }, +}) + +describe('byteOffsetToCharOffset', () => { + it('is identity for ASCII', () => { + expect(byteOffsetToCharOffset('const x = 1', 6)).toBe(6) + }) + + it('accounts for multibyte characters before the offset', () => { + // "héllo": 'é' is 2 bytes, so byte offset 3 == char offset 2. + expect(byteOffsetToCharOffset('héllo', 3)).toBe(2) + }) + + it('clamps to the string bounds', () => { + expect(byteOffsetToCharOffset('abc', -5)).toBe(0) + expect(byteOffsetToCharOffset('abc', 999)).toBe(3) + }) +}) + +describe('parseEvent', () => { + it('parses a valid match event', () => { + const line = JSON.stringify({ type: 'match', data: { path: { text: 'a.ts' } } }) + expect(parseEvent(line)?.type).toBe('match') + }) + + it('returns null for blank lines, bad JSON, and unknown types', () => { + expect(parseEvent('')).toBeNull() + expect(parseEvent(' ')).toBeNull() + expect(parseEvent('{not json')).toBeNull() + expect(parseEvent(JSON.stringify({ type: 'mystery' }))).toBeNull() + expect(parseEvent(JSON.stringify({ data: {} }))).toBeNull() + }) +}) + +describe('groupEvents', () => { + it('groups a begin..end block into one file with match lines', () => { + const files = groupEvents([ + begin('a.ts'), + match('a.ts', 10, 'const foo = 1', [{ start: 6, end: 9 }]), + match('a.ts', 20, 'foo()', [{ start: 0, end: 3 }]), + end('a.ts'), + ]) + expect(files).toHaveLength(1) + expect(files[0].path).toBe('a.ts') + expect(files[0].lines).toHaveLength(2) + expect(files[0].matchCount).toBe(2) + expect(files[0].lines[0].ranges).toEqual([{ start: 6, end: 9 }]) + }) + + it('counts each submatch on a line', () => { + const files = groupEvents([ + begin('a.ts'), + match('a.ts', 1, 'foo foo foo', [ + { start: 0, end: 3 }, + { start: 4, end: 7 }, + { start: 8, end: 11 }, + ]), + end('a.ts'), + ]) + expect(files[0].matchCount).toBe(3) + expect(files[0].lines[0].ranges).toHaveLength(3) + }) + + it('keeps context lines with empty ranges and does not count them', () => { + const files = groupEvents([ + begin('a.ts'), + context('a.ts', 9, 'before'), + match('a.ts', 10, 'foo', [{ start: 0, end: 3 }]), + context('a.ts', 11, 'after'), + end('a.ts'), + ]) + expect(files[0].lines).toHaveLength(3) + expect(files[0].matchCount).toBe(1) + const contextLines = files[0].lines.filter((l) => l.ranges.length === 0) + expect(contextLines.map((l) => l.text)).toEqual(['before', 'after']) + }) + + it('drops files that produced no matches and ignores summary events', () => { + const files = groupEvents([ + begin('empty.ts'), + context('empty.ts', 1, 'just context'), + end('empty.ts'), + { type: 'summary', data: {} }, + ]) + expect(files).toHaveLength(0) + }) + + it('finalizes a file left open with no trailing end (cap/timeout kill)', () => { + const files = groupEvents([ + begin('a.ts'), + match('a.ts', 1, 'foo', [{ start: 0, end: 3 }]), + // no end event — ripgrep was killed mid-file + ]) + expect(files).toHaveLength(1) + expect(files[0].matchCount).toBe(1) + }) + + it('leaves relativePath empty for the caller to fill', () => { + const files = groupEvents([ + begin('/abs/a.ts'), + match('/abs/a.ts', 1, 'foo', [{ start: 0, end: 3 }]), + end('/abs/a.ts'), + ]) + expect(files[0].relativePath).toBe('') + }) +}) diff --git a/src/main/search/ripgrepParser.ts b/src/main/search/ripgrepParser.ts new file mode 100644 index 00000000..63b90156 --- /dev/null +++ b/src/main/search/ripgrepParser.ts @@ -0,0 +1,162 @@ +// ============================================================================= +// ripgrepParser — pure helpers to parse ripgrep's `--json` event stream into +// per-file search results. No I/O, so it is fully unit-testable. +// +// ripgrep emits one JSON object per line. The event types we care about: +// begin — start of matches in a file +// match — a matching line (with submatch byte offsets) +// context — a surrounding context line (only when --context is used) +// end — end of a file's matches +// summary — overall stats (ignored here) +// ============================================================================= + +import type { SearchFileResult, SearchResultLine, SearchMatchRange } from '../../shared/types' + +/** Longest line text we keep; longer lines are truncated for display. */ +const MAX_LINE_LENGTH = 2000 + +interface RgText { + text?: string + bytes?: string // base64, for non-UTF8 content +} + +interface RgSubmatch { + match: RgText + start: number // byte offset into the line bytes + end: number // byte offset into the line bytes (exclusive) +} + +interface RgMatchData { + path: RgText + lines: RgText + line_number?: number + submatches?: RgSubmatch[] +} + +export type RgEvent = + | { type: 'begin'; data: { path: RgText } } + | { type: 'match'; data: RgMatchData } + | { type: 'context'; data: RgMatchData } + | { type: 'end'; data: { path: RgText } } + | { type: 'summary'; data: unknown } + +const KNOWN_TYPES = new Set(['begin', 'match', 'context', 'end', 'summary']) + +/** Parse a single line of ripgrep `--json` output. Returns null for blank lines, + * malformed JSON, or unknown event types. */ +export function parseEvent(line: string): RgEvent | null { + const trimmed = line.trim() + if (!trimmed) return null + let obj: unknown + try { + obj = JSON.parse(trimmed) + } catch { + return null + } + if ( + !obj || + typeof obj !== 'object' || + typeof (obj as { type?: unknown }).type !== 'string' || + !KNOWN_TYPES.has((obj as { type: string }).type) + ) { + return null + } + return obj as RgEvent +} + +/** Resolve an RgText to a string, decoding base64 bytes when `text` is absent. */ +function textOf(t: RgText | undefined): string { + if (!t) return '' + if (typeof t.text === 'string') return t.text + if (typeof t.bytes === 'string') { + try { + return Buffer.from(t.bytes, 'base64').toString('utf-8') + } catch { + return '' + } + } + return '' +} + +/** Convert a byte offset into a UTF-8 string to its character (code-unit) offset. + * ripgrep emits submatch offsets aligned to character boundaries, so the slice + * below never splits a multi-byte sequence in practice. */ +export function byteOffsetToCharOffset(text: string, byteOffset: number): number { + if (byteOffset <= 0) return 0 + const buf = Buffer.from(text, 'utf-8') + if (byteOffset >= buf.length) return text.length + return buf.subarray(0, byteOffset).toString('utf-8').length +} + +/** Build a SearchResultLine from a match/context event. Context lines (no + * submatches) get an empty `ranges` array. */ +function lineFromEvent(data: RgMatchData): SearchResultLine | null { + const lineNumber = data.line_number + if (typeof lineNumber !== 'number') return null + + // ripgrep includes the trailing newline in lines.text; strip it for display. + let text = textOf(data.lines).replace(/\r?\n$/, '') + + const ranges: SearchMatchRange[] = [] + for (const sm of data.submatches ?? []) { + const start = byteOffsetToCharOffset(text, sm.start) + const end = byteOffsetToCharOffset(text, sm.end) + if (end > start) ranges.push({ start, end }) + } + + // Truncate very long lines, dropping/clamping ranges past the cap. + if (text.length > MAX_LINE_LENGTH) { + text = text.slice(0, MAX_LINE_LENGTH) + for (let i = ranges.length - 1; i >= 0; i--) { + if (ranges[i].start >= MAX_LINE_LENGTH) ranges.splice(i, 1) + else if (ranges[i].end > MAX_LINE_LENGTH) ranges[i].end = MAX_LINE_LENGTH + } + } + + return { line: lineNumber, text, ranges } +} + +/** + * Group a flat list of ripgrep events into per-file results, in the order files + * were emitted. `matchCount` counts individual submatches (matching VS Code's + * "N results" semantics). `relativePath` is left empty — the caller fills it in + * once the search root is known. + */ +export function groupEvents(events: RgEvent[]): SearchFileResult[] { + const files: SearchFileResult[] = [] + let current: SearchFileResult | null = null + + for (const ev of events) { + switch (ev.type) { + case 'begin': + current = { path: textOf(ev.data.path), relativePath: '', lines: [], matchCount: 0 } + break + case 'match': + case 'context': { + if (!current) break + const line = lineFromEvent(ev.data) + if (line) { + current.lines.push(line) + current.matchCount += line.ranges.length + } + break + } + case 'end': + if (current) { + // Only keep files that actually had matches. + if (current.matchCount > 0) files.push(current) + current = null + } + break + case 'summary': + default: + break + } + } + + // Finalize a file left open (no trailing `end`) — happens when ripgrep is + // killed mid-file at the result cap or timeout. Don't drop its matches. + if (current && current.matchCount > 0) files.push(current) + + return files +} diff --git a/src/main/search/ripgrepPath.ts b/src/main/search/ripgrepPath.ts new file mode 100644 index 00000000..cd203834 --- /dev/null +++ b/src/main/search/ripgrepPath.ts @@ -0,0 +1,21 @@ +// ============================================================================= +// ripgrepPath — resolve the bundled ripgrep binary at runtime. +// +// @vscode/ripgrep exports `rgPath` pointing at the platform binary (e.g. +// node_modules/@vscode/ripgrep-darwin-arm64/bin/rg). In a packaged build that +// path sits under app.asar; the binary is unpacked to app.asar.unpacked, so we +// apply the same .asar -> .asar.unpacked swap used by marketplace.ts. +// ============================================================================= + +import { rgPath } from '@vscode/ripgrep' + +let cached: string | null = null + +export function getRgPath(): string { + if (cached) return cached + cached = + rgPath.includes('app.asar') && !rgPath.includes('app.asar.unpacked') + ? rgPath.replace('app.asar', 'app.asar.unpacked') + : rgPath + return cached +} diff --git a/src/main/search/searchEngine.ts b/src/main/search/searchEngine.ts new file mode 100644 index 00000000..250752d7 --- /dev/null +++ b/src/main/search/searchEngine.ts @@ -0,0 +1,206 @@ +// ============================================================================= +// searchEngine — spawn ripgrep, stream parsed results in batches, support +// cancellation and a total-match cap. +// ============================================================================= + +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' +import path from 'path' +import log from '../logger' +import type { SearchOptions, SearchFileResult } from '../../shared/types' +import { buildRipgrepArgs } from './ripgrepArgs' +import { parseEvent, groupEvents, type RgEvent } from './ripgrepParser' +import { getRgPath } from './ripgrepPath' + +// Caps total matches. Kept modest because the results list isn't virtualized — +// rendering many thousands of rows (e.g. when "use ignore files" is off and the +// search descends into node_modules) is what causes a brief UI stall. +const DEFAULT_MAX_RESULTS = 2000 +/** Coalesce completed files into batches flushed on this cadence (ms). */ +const FLUSH_INTERVAL_MS = 40 +/** Hard wall-clock cap so a pathological regex can't hang ripgrep forever. */ +const SEARCH_TIMEOUT_MS = 15000 + +export interface SearchStats { + matches: number + files: number + truncated: boolean +} + +export interface SearchCallbacks { + onBatch: (files: SearchFileResult[]) => void + onDone: (stats: SearchStats, error?: string) => void +} + +export interface SearchHandle { + cancel: () => void +} + +/** Make a search result path relative to the root, with forward slashes. */ +function toRelative(rootPath: string, filePath: string): string { + const rel = path.relative(rootPath, filePath) + return rel.split(path.sep).join('/') +} + +/** + * Run a streaming ripgrep search. Returns a handle whose `cancel()` kills the + * underlying process. Results are delivered to `onBatch` as files complete and + * `onDone` fires exactly once when the search ends, is cancelled, or fails. + */ +export function runSearch( + opts: SearchOptions, + rootPath: string, + extraExcludes: string[], + callbacks: SearchCallbacks, +): SearchHandle { + const maxResults = opts.maxResults ?? DEFAULT_MAX_RESULTS + const args = buildRipgrepArgs(opts, rootPath, extraExcludes) + + let child: ChildProcessWithoutNullStreams | null = null + let finished = false + let cancelled = false + + // Streaming state. + let stdoutBuf = '' + let stderrBuf = '' + let pending: SearchFileResult[] = [] + let totalMatches = 0 + let totalFiles = 0 + let truncated = false + let flushTimer: ReturnType | null = null + let watchdog: ReturnType | null = null + // Carry the open file's events across stdout chunks until its `end` arrives. + let fileEvents: RgEvent[] = [] + + const clearFlushTimer = (): void => { + if (flushTimer) { + clearTimeout(flushTimer) + flushTimer = null + } + } + + const flush = (): void => { + clearFlushTimer() + if (pending.length === 0) return + const batch = pending + pending = [] + if (!cancelled) callbacks.onBatch(batch) + } + + const scheduleFlush = (): void => { + if (flushTimer) return + flushTimer = setTimeout(flush, FLUSH_INTERVAL_MS) + } + + const finishOnce = (error?: string): void => { + if (finished) return + finished = true + clearFlushTimer() + if (watchdog) { + clearTimeout(watchdog) + watchdog = null + } + flush() + callbacks.onDone({ matches: totalMatches, files: totalFiles, truncated }, error) + } + + const stopAtCap = (): void => { + truncated = true + try { + child?.kill('SIGTERM') + } catch { + /* noop */ + } + } + + // Process one completed file's events (begin..end) into a result. + const consumeFileEvents = (): void => { + if (fileEvents.length === 0) return + const [fileResult] = groupEvents(fileEvents) + fileEvents = [] + if (!fileResult) return + fileResult.relativePath = toRelative(rootPath, fileResult.path) + totalFiles += 1 + totalMatches += fileResult.matchCount + pending.push(fileResult) + scheduleFlush() + if (totalMatches >= maxResults) stopAtCap() + } + + const handleLine = (line: string): void => { + const ev = parseEvent(line) + if (!ev) return + if (ev.type === 'summary') return + fileEvents.push(ev) + if (ev.type === 'end') consumeFileEvents() + } + + const handleStdout = (chunk: Buffer): void => { + stdoutBuf += chunk.toString('utf-8') + let nl = stdoutBuf.indexOf('\n') + while (nl !== -1) { + const line = stdoutBuf.slice(0, nl) + stdoutBuf = stdoutBuf.slice(nl + 1) + handleLine(line) + nl = stdoutBuf.indexOf('\n') + } + } + + try { + child = spawn(getRgPath(), args, { cwd: rootPath }) + } catch (err) { + finishOnce(err instanceof Error ? err.message : String(err)) + return { cancel: () => { cancelled = true } } + } + + // Watchdog: stop a runaway search (e.g. catastrophic regex backtracking). + watchdog = setTimeout(() => { + truncated = true + try { + child?.kill('SIGTERM') + } catch { + /* noop */ + } + }, SEARCH_TIMEOUT_MS) + + child.stdout.on('data', handleStdout) + child.stderr.on('data', (chunk: Buffer) => { + if (stderrBuf.length < 4096) stderrBuf += chunk.toString('utf-8') + }) + child.on('error', (err) => { + log.warn('[search] ripgrep spawn error:', err) + finishOnce(err.message) + }) + child.on('close', (code) => { + // Drain any trailing line without a newline. + if (stdoutBuf.trim()) handleLine(stdoutBuf) + stdoutBuf = '' + // Flush a file left open when ripgrep was killed mid-file (cap/timeout) so + // its matches aren't silently dropped. + consumeFileEvents() + + if (cancelled) { + finishOnce() + return + } + // ripgrep exit codes: 0 = matches, 1 = no matches, 2 = error. + // When we killed it at the cap, code is null (signal) — that's expected. + if (code === 2 && !truncated) { + const msg = stderrBuf.trim() || 'Search failed' + finishOnce(msg) + return + } + finishOnce() + }) + + return { + cancel: () => { + if (cancelled || finished) return + cancelled = true + try { + child?.kill('SIGTERM') + } catch { + /* noop */ + } + }, + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1d3815c5..204401cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -101,6 +101,10 @@ import { FS_COPY, FS_IMPORT_ENTRIES, FS_SEARCH, + SEARCH_START, + SEARCH_CANCEL, + SEARCH_RESULT, + SEARCH_DONE, SHELL_SHOW_IN_FOLDER, NOTIFY_OS, NOTIFY_ACTION, @@ -198,7 +202,7 @@ import { AUTH_DELETE, PERF_GET, } from '../shared/ipc-channels' -import type { AppSettings, SidebarSession } from '../shared/types' +import type { AppSettings, SidebarSession, SearchOptions, SearchResultBatch, SearchDoneEvent } from '../shared/types' // Cache native-fullscreen state so renderer drag handlers can synchronously // check it without an IPC round-trip on every mousemove. Main BROADCASTS @@ -347,6 +351,38 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, + // --------------------------------------------------------------------------- + // Content search (ripgrep-backed Search view) + // --------------------------------------------------------------------------- + + searchStart(rootPath: string, searchId: string, options: SearchOptions): Promise { + return ipcRenderer.invoke(SEARCH_START, rootPath, searchId, options) + }, + + searchCancel(): Promise { + return ipcRenderer.invoke(SEARCH_CANCEL) + }, + + onSearchResult(callback: (batch: SearchResultBatch) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, batch: SearchResultBatch): void => { + callback(batch) + } + ipcRenderer.on(SEARCH_RESULT, listener) + return () => { + ipcRenderer.removeListener(SEARCH_RESULT, listener) + } + }, + + onSearchDone(callback: (event: SearchDoneEvent) => void): () => void { + const listener = (_event: Electron.IpcRendererEvent, done: SearchDoneEvent): void => { + callback(done) + } + ipcRenderer.on(SEARCH_DONE, listener) + return () => { + ipcRenderer.removeListener(SEARCH_DONE, listener) + } + }, + // --------------------------------------------------------------------------- // Git // --------------------------------------------------------------------------- diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d42b2772..ac3fbda2 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { setCanvasOperations } from './stores/appStore' import { createCanvasOps } from './lib/canvasBridge' import { useSettingsStore } from './stores/settingsStore' import { useUIStore } from './stores/uiStore' +import { useFileDropTracker, FileDropOverlay } from './drag/fileDropTarget' import { useUpdateStore, type UpdateStatus } from './stores/updateStore' import { useShortcuts } from './hooks/useShortcuts' import { useProcessMonitor } from './hooks/useProcessMonitor' @@ -114,6 +115,10 @@ function MainApp() { // Guards against stacking reload-confirm dialogs when the detector re-fires. const reloadPromptOpenRef = useRef(false) + // Track the active file-drag drop target (canvas / dock / agent) for the + // single shared drop indicator ( below). + useFileDropTracker() + // Store state const currentWorkspace = useSelectedWorkspace() @@ -529,6 +534,9 @@ function MainApp() {
+ {/* Single shared file-drag drop indicator (canvas / dock / agent) */} + + {/* Modal overlays */} {showNodeSwitcher && } {showCommandPalette && } diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 4a61579d..9d79b345 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -15,6 +15,7 @@ import SnapGuides from './SnapGuides' import CanvasRegionComponent from './CanvasRegionComponent' import type { Point, PanelType } from '../../shared/types' import { openFileAsPanel } from '../lib/fileRouting' +import { setPendingReveal } from '../lib/editorReveal' // Module-level style injection — shared across all Canvas instances let canvasStyleInjected = false @@ -273,6 +274,12 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = // Support internal multi-file drops… const multiData = e.dataTransfer.getData('application/cate-files') const singlePath = e.dataTransfer.getData('application/cate-file') + // Optional open-at-line payload (dragging a specific search-result line). + let lineReveal: { path: string; line: number; column?: number } | null = null + const lineRaw = e.dataTransfer.getData('application/cate-file-line') + if (lineRaw) { + try { lineReveal = JSON.parse(lineRaw) } catch { /* ignore */ } + } let filePaths: string[] = [] if (multiData) { try { filePaths = JSON.parse(multiData) } catch { /* ignore */ } @@ -290,6 +297,7 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = if (filePaths.length === 0) return e.preventDefault() + e.stopPropagation() const rect = canvasRef.current?.getBoundingClientRect() if (!rect) return const viewPoint = { x: e.clientX - rect.left, y: e.clientY - rect.top } @@ -309,7 +317,10 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = // Drop of a folder → spawn a terminal scoped to that path. useAppStore.getState().createTerminal(wsId, undefined, pos, undefined, filePath) } else { - openFileAsPanel(wsId, filePath, pos) + const panelId = openFileAsPanel(wsId, filePath, pos) + if (panelId && lineReveal && lineReveal.path === filePath) { + setPendingReveal(panelId, { line: lineReveal.line, column: lineReveal.column }) + } } offsetX += 40 } @@ -422,6 +433,8 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = ref={canvasRef} data-canvas-container data-canvas-panel-id={panelId} + data-filedrop="canvas" + data-filedrop-id={panelId} className="relative w-full h-full overflow-hidden bg-canvas-bg" style={{ cursor: idleCursor }} onMouseDown={handleMouseDown} diff --git a/src/renderer/docking/DockZone.tsx b/src/renderer/docking/DockZone.tsx index 69fece97..5621e325 100644 --- a/src/renderer/docking/DockZone.tsx +++ b/src/renderer/docking/DockZone.tsx @@ -10,6 +10,9 @@ import type { DockZonePosition, DockLayoutNode, PanelState } from '../../shared/ import DockTabStack from './DockTabStack' import DockSplitContainer from './DockSplitContainer' import { registerDropZone } from '../drag' +import { openFileAsPanel } from '../lib/fileRouting' +import { setPendingReveal } from '../lib/editorReveal' +import { useAppStore } from '../stores/appStore' interface DockZoneProps { position: DockZonePosition @@ -35,6 +38,62 @@ export default function DockZone({ position, renderPanel, getPanelTitle, onClose }) }, [position]) + // Native file drop (from Search results, the Explorer, or the OS) → open the + // file(s) as editor tabs in this zone. The drop indicator itself is rendered + // globally by (this div is marked data-filedrop="dock"). + // The canvas handles its own area and stops propagation, so canvas drops + // still open floating nodes. + const handleFileDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('application/cate-file') || e.dataTransfer.types.includes('Files')) { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + } + }, []) + + const handleFileDrop = useCallback( + async (e: React.DragEvent) => { + const multiData = e.dataTransfer.getData('application/cate-files') + const singlePath = e.dataTransfer.getData('application/cate-file') + let paths: string[] = [] + if (multiData) { + try { paths = JSON.parse(multiData) } catch { /* ignore */ } + } + if (paths.length === 0 && singlePath) paths = [singlePath] + if (paths.length === 0 && e.dataTransfer.files.length > 0) { + for (const f of Array.from(e.dataTransfer.files)) { + const p = (f as { path?: string }).path + if (p) paths.push(p) + } + } + if (paths.length === 0) return + + e.preventDefault() + e.stopPropagation() + + let lineReveal: { path: string; line: number; column?: number } | null = null + const lineRaw = e.dataTransfer.getData('application/cate-file-line') + if (lineRaw) { + try { lineReveal = JSON.parse(lineRaw) } catch { /* ignore */ } + } + + const wsId = workspaceId ?? useAppStore.getState().selectedWorkspaceId + if (!wsId) return + for (const filePath of paths) { + let isDir = false + try { + const st = await window.electronAPI.fsStat(filePath) + isDir = !!st?.isDirectory + } catch { /* treat as file */ } + if (isDir) continue // dock tabs don't host folders + const panelId = openFileAsPanel(wsId, filePath, undefined, { target: 'dock', zone: position }) + if (panelId && lineReveal && lineReveal.path === filePath) { + setPendingReveal(panelId, { line: lineReveal.line, column: lineReveal.column }) + } + } + }, + [workspaceId, position], + ) + const renderNode = useCallback( (node: DockLayoutNode, leftEdge = false, rightEdge = false): React.ReactNode => { if (node.type === 'tabs') { @@ -82,8 +141,13 @@ export default function DockZone({ position, renderPanel, getPanelTitle, onClose return (
{zone.layout ? renderNode(zone.layout, true, true) : ( // Empty center zone — show background diff --git a/src/renderer/drag/fileDropTarget.tsx b/src/renderer/drag/fileDropTarget.tsx new file mode 100644 index 00000000..76b8d4f9 --- /dev/null +++ b/src/renderer/drag/fileDropTarget.tsx @@ -0,0 +1,140 @@ +// ============================================================================= +// fileDropTarget — single source of truth for the HTML5 file-drag drop +// indicator. A window-level tracker hit-tests the cursor against the nearest +// element marked [data-filedrop] (canvas / dock zone / agent panel) and +// publishes ONE active target; renders a single indicator +// at that target's bounds. This mirrors the internal drag system's +// single-target model, so indicators never conflict, are correctly scoped, and +// clear on drop. Drop *handling* stays in the components; this is visual only. +// ============================================================================= + +import React, { useEffect } from 'react' +import { create } from 'zustand' + +export type FileDropKind = 'canvas' | 'dock' | 'agent' | 'terminal' + +interface FileDropTarget { + kind: FileDropKind + id: string + rect: { left: number; top: number; width: number; height: number } +} + +interface FileDropState { + target: FileDropTarget | null + set: (t: FileDropTarget | null) => void +} + +const useFileDropStore = create((set) => ({ + target: null, + set: (target) => set({ target }), +})) + +function isFileDrag(e: DragEvent): boolean { + const types = e.dataTransfer?.types + if (!types) return false + return ( + types.includes('application/cate-file') || + types.includes('application/cate-files') || + types.includes('Files') + ) +} + +/** Install window-level listeners that track the current file-drop target. + * Call once per window (e.g. in the main app shell). */ +export function useFileDropTracker(): void { + useEffect(() => { + const onDragOver = (e: DragEvent): void => { + if (!isFileDrag(e)) return + e.preventDefault() // allow dropping anywhere a [data-filedrop] target exists + const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null + const host = el?.closest('[data-filedrop]') as HTMLElement | null + const store = useFileDropStore.getState() + if (!host) { + if (store.target) store.set(null) + return + } + const kind = host.getAttribute('data-filedrop') as FileDropKind + const id = host.getAttribute('data-filedrop-id') ?? '' + // Avoid churn: only recompute the rect when the target element changes. + if (store.target && store.target.kind === kind && store.target.id === id) return + const r = host.getBoundingClientRect() + // Clamp horizontally so the indicator doesn't slide under the open + // sidebars (which are absolute overlays spanning the canvas full-width). + const cs = getComputedStyle(document.documentElement) + const leftInset = parseFloat(cs.getPropertyValue('--cate-left-sidebar-width')) || 0 + const rightInset = parseFloat(cs.getPropertyValue('--cate-right-sidebar-width')) || 0 + const left = Math.max(r.left, leftInset) + const right = Math.min(r.right, window.innerWidth - rightInset) + store.set({ kind, id, rect: { left, top: r.top, width: Math.max(0, right - left), height: r.height } }) + } + const clear = (): void => { + if (useFileDropStore.getState().target) useFileDropStore.getState().set(null) + } + const onDragLeave = (e: DragEvent): void => { + // relatedTarget null === cursor left the window entirely. + if (!e.relatedTarget) clear() + } + // Capture phase: fire before any target handler's stopPropagation (the + // terminal stops dragover/drop propagation), so the tracker always updates. + window.addEventListener('dragover', onDragOver, true) + window.addEventListener('drop', clear, true) + window.addEventListener('dragend', clear, true) + window.addEventListener('dragleave', onDragLeave, true) + return () => { + window.removeEventListener('dragover', onDragOver, true) + window.removeEventListener('drop', clear, true) + window.removeEventListener('dragend', clear, true) + window.removeEventListener('dragleave', onDragLeave, true) + } + }, []) +} + +const LABEL: Record = { + canvas: 'Drop to open on canvas', + dock: 'Drop to open here', + agent: 'Drop file to add to chat', + terminal: 'Drop to paste path', +} + +/** Single indicator for the active file-drop target. Mirrors the internal + * drag indicator's dashed-blue style so file drops feel consistent. */ +export const FileDropOverlay: React.FC = () => { + const target = useFileDropStore((s) => s.target) + if (!target) return null + const { rect, kind } = target + return ( +
+ + {LABEL[kind]} + +
+ ) +} diff --git a/src/renderer/hooks/useShortcuts.ts b/src/renderer/hooks/useShortcuts.ts index 3ffbb675..83ab583e 100644 --- a/src/renderer/hooks/useShortcuts.ts +++ b/src/renderer/hooks/useShortcuts.ts @@ -8,6 +8,7 @@ import { useShortcutStore } from '../stores/shortcutStore' import { useCanvasStoreApi } from '../stores/CanvasStoreContext' import { useAppStore } from '../stores/appStore' import { useUIStore } from '../stores/uiStore' +import { useSearchStore } from '../stores/searchStore' import type { MenuActionId, ShortcutAction } from '../../shared/types' import { confirmDeleteRegion } from '../lib/confirmDeleteRegion' @@ -109,6 +110,16 @@ export function useShortcuts(): void { } break } + case 'toggleSearch': { + const ui = useUIStore.getState() + const side = ui.sidebarLayout.left.includes('search') ? 'left' : 'right' + const active = side === 'left' ? ui.activeLeftSidebarView : ui.activeRightSidebarView + const next = active === 'search' ? null : 'search' + if (side === 'left') ui.setActiveLeftSidebarView(next) + else ui.setActiveRightSidebarView(next) + if (next === 'search') useSearchStore.getState().requestFocus() + break + } case 'toggleMinimap': useUIStore.getState().toggleMinimapOpen() break @@ -309,9 +320,10 @@ export function useShortcuts(): void { const ui = useUIStore.getState() // Single-key tool/navigation shortcuts (V, H, arrows) must not fire while - // typing in a terminal/editor, or steal arrows from the open overlays. + // typing in a terminal/editor, or steal arrows from the open overlays or a + // keyboard-navigable list (e.g. the Search results tree, marked data-keynav). if (TOOL_AND_NAV_ACTIONS.has(action)) { - if (terminalHasFocus || isTextSurfaceFocused()) return + if (terminalHasFocus || isTextSurfaceFocused() || isKeyNavFocused()) return const navOnly = action !== 'toolSelect' && action !== 'toolHand' if (navOnly && (ui.showNodeSwitcher || ui.showCommandPalette)) return } @@ -371,6 +383,14 @@ export function useShortcuts(): void { return false } + /** True when focus is inside a list that handles its own arrow keys (e.g. + * the Search results tree). Such surfaces opt out via `data-keynav` so the + * global canvas-navigation shortcuts don't steal their arrow keys. */ + function isKeyNavFocused(): boolean { + const active = document.activeElement as HTMLElement | null + return !!active?.closest('[data-keynav]') + } + document.addEventListener('keydown', handleKeyDown, { capture: true }) document.addEventListener('keyup', handleKeyUp, { capture: true }) diff --git a/src/renderer/lib/e2eHarness.ts b/src/renderer/lib/e2eHarness.ts index bea1f739..e7ab9ffa 100644 --- a/src/renderer/lib/e2eHarness.ts +++ b/src/renderer/lib/e2eHarness.ts @@ -6,11 +6,36 @@ // the UI for setup is brittle; reaching into stores is reliable. import { useAppStore } from '../stores/appStore' +import { useUIStore, type SidebarView } from '../stores/uiStore' import { getOrCreateCanvasStoreForPanel } from '../stores/canvasStore' import { useDragStore } from '../drag/store' +import { useSearchStore } from '../stores/searchStore' +import { getLastReveal } from './editorReveal' +import { applyTheme, getAllThemes } from './themeManager' import { terminalRegistry } from './terminalRegistry' import type { Point } from '../../shared/types' +/** Serializable snapshot of the search store for e2e assertions. */ +export interface SearchSnapshot { + query: string + isRegex: boolean + matchCase: boolean + wholeWord: boolean + includes: string + excludes: string + respectIgnore: boolean + optionsExpanded: boolean + status: string + searchId: string | null + truncated: boolean + error: string | null + fileCount: number + filePaths: string[] + totalMatches: number + dismissedFiles: number + dismissedLines: number +} + declare global { interface Window { __cateE2E?: { @@ -27,6 +52,21 @@ declare global { terminalPtyId(nodeId: string): string | null /** Write raw data to a terminal node's PTY (e.g. a flooding command). */ writeTerminal(nodeId: string, data: string): boolean + /** Point the selected workspace at a real directory (registers it as an + * allowed root) so content search has files to scan. */ + setWorkspaceRoot(rootPath: string): Promise + /** Activate a sidebar view (e.g. 'search') on the left activity bar. */ + openSidebarView(view: SidebarView): void + /** File paths of currently-open editor panels (for open-at-match asserts). */ + editorPaths(): string[] + /** Serializable snapshot of the search store (query, options, results). */ + getSearchSnapshot(): SearchSnapshot + /** The most recent editor reveal request (panelId + line/column), or null. */ + lastEditorReveal(): { panelId: string; line: number; column?: number } | null + /** Apply a theme by id (for cross-theme visual checks). */ + setTheme(id: string): void + /** All available theme ids + their light/dark type. */ + themeIds(): { id: string; type: string }[] dragSnapshot(): { isDragging: boolean sourceKind: string | null @@ -133,6 +173,53 @@ export function installE2EHarness(): void { return true } + const setWorkspaceRoot = (rootPath: string): Promise => { + const wsId = useAppStore.getState().selectedWorkspaceId + return useAppStore.getState().setWorkspaceRootPath(wsId, rootPath) + } + + const openSidebarView = (view: SidebarView): void => { + useUIStore.getState().setActiveLeftSidebarView(view) + } + + const editorPaths = (): string[] => { + const s = useAppStore.getState() + const ws = s.workspaces.find((w) => w.id === s.selectedWorkspaceId) + if (!ws) return [] + return Object.values(ws.panels) + .filter((p) => p.type === 'editor' && !!p.filePath) + .map((p) => p.filePath as string) + } + + const getSearchSnapshot = (): SearchSnapshot => { + const s = useSearchStore.getState() + return { + query: s.query, + isRegex: s.isRegex, + matchCase: s.matchCase, + wholeWord: s.wholeWord, + includes: s.includes, + excludes: s.excludes, + respectIgnore: s.respectIgnore, + optionsExpanded: s.optionsExpanded, + status: s.status, + searchId: s.currentSearchId, + truncated: s.truncated, + error: s.error, + fileCount: s.files.length, + filePaths: s.files.map((f) => f.relativePath), + totalMatches: s.files.reduce((n, f) => n + f.matchCount, 0), + dismissedFiles: s.dismissedFiles.size, + dismissedLines: s.dismissedLines.size, + } + } + + const lastEditorReveal = () => getLastReveal() + + const setTheme = (id: string): void => applyTheme(id) + const themeIds = (): { id: string; type: string }[] => + getAllThemes().map((t) => ({ id: t.id, type: t.type })) + const dragSnapshot = () => { const s = useDragStore.getState() return { @@ -156,6 +243,13 @@ export function installE2EHarness(): void { resetViewport, terminalPtyId, writeTerminal, + setWorkspaceRoot, + openSidebarView, + editorPaths, + getSearchSnapshot, + lastEditorReveal, + setTheme, + themeIds, dragSnapshot, } } diff --git a/src/renderer/lib/editorReveal.ts b/src/renderer/lib/editorReveal.ts index a3ab2360..597d1f3d 100644 --- a/src/renderer/lib/editorReveal.ts +++ b/src/renderer/lib/editorReveal.ts @@ -17,9 +17,13 @@ export interface EditorReveal { const pending = new Map() +/** Last reveal requested, kept for e2e assertions (not consumed by editors). */ +let lastReveal: (EditorReveal & { panelId: string }) | null = null + /** Record where a freshly-created editor panel should jump once it mounts. */ export function setPendingReveal(panelId: string, reveal: EditorReveal): void { pending.set(panelId, reveal) + lastReveal = { panelId, ...reveal } } /** Consume the pending reveal for a panel (one-shot — cleared on read). */ @@ -28,3 +32,8 @@ export function takePendingReveal(panelId: string): EditorReveal | undefined { if (reveal) pending.delete(panelId) return reveal } + +/** The most recent reveal request (for tests). Persists across consumption. */ +export function getLastReveal(): (EditorReveal & { panelId: string }) | null { + return lastReveal +} diff --git a/src/renderer/lib/fsWatchManager.ts b/src/renderer/lib/fsWatchManager.ts new file mode 100644 index 00000000..ad3ce6be --- /dev/null +++ b/src/renderer/lib/fsWatchManager.ts @@ -0,0 +1,71 @@ +// ============================================================================= +// fsWatchManager — renderer-side refcounted multiplexer over the main-process +// filesystem watcher. +// +// Main keys watch subscriptions by (windowId, path), so if two components in the +// SAME window each call fsWatchStart for the same root, the second start evicts +// the first and either one's fsWatchStop tears the shared watcher down for both +// (see filesystem.ts watchStart/watchStop). The Explorer (left sidebar) and the +// Search view (right sidebar) can be mounted at the same time, so they'd clobber +// each other that way. +// +// This manager refcounts per root path on the renderer side: it issues exactly +// one fsWatchStart on the 0->1 transition and one fsWatchStop on the 1->0 +// transition, and fans the single onFsWatchEvent stream out to every subscriber. +// ============================================================================= + +export interface FsWatchEvent { + type: 'create' | 'update' | 'delete' + path: string +} +type Listener = (event: FsWatchEvent) => void + +interface Entry { + listeners: Set + unsubscribe: (() => void) | null +} + +const entries = new Map() + +/** Normalize OS-native separators so a path/prefix comparison is consistent. */ +function toPosix(p: string): string { + return p.indexOf('\\') === -1 ? p : p.replace(/\\/g, '/') +} + +/** + * Subscribe to filesystem-change events under `rootPath`. Returns an unsubscribe + * function. The underlying watcher is shared and reference-counted, so it stays + * alive as long as at least one subscriber for that root remains. + */ +export function watchFsRoot(rootPath: string, listener: Listener): () => void { + if (!rootPath || !window.electronAPI) return () => {} + + let entry = entries.get(rootPath) + if (!entry) { + const created: Entry = { listeners: new Set(), unsubscribe: null } + entries.set(rootPath, created) + window.electronAPI.fsWatchStart(rootPath).catch(() => { /* watcher unavailable */ }) + const rootPosix = toPosix(rootPath) + // onFsWatchEvent delivers every watch event for this window; only forward + // those under this root (matters when multiple roots are watched at once). + created.unsubscribe = window.electronAPI.onFsWatchEvent((event) => { + if (toPosix(event.path).startsWith(rootPosix)) { + entries.get(rootPath)?.listeners.forEach((l) => l(event)) + } + }) + entry = created + } + + entry.listeners.add(listener) + + return () => { + const e = entries.get(rootPath) + if (!e) return + e.listeners.delete(listener) + if (e.listeners.size === 0) { + e.unsubscribe?.() + entries.delete(rootPath) + window.electronAPI?.fsWatchStop(rootPath).catch(() => { /* already gone */ }) + } + } +} diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index 8ce6ce68..fc3a2abc 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -16,6 +16,7 @@ import { useEffect, useRef, useState, useCallback } from 'react' import type { TerminalPanelProps } from './types' import { terminalRegistry } from '../lib/terminalRegistry' +import { formatTerminalPaste, type DroppedRef } from './terminalDrop' import { useAppStore } from '../stores/appStore' import { useCanvasStoreContext, useCanvasStoreApi } from '../stores/CanvasStoreContext' @@ -574,8 +575,6 @@ export default function TerminalPanel({ // Drag-and-drop: accept files from OS or internal file explorer // ------------------------------------------------------------------------- - const [isDragOver, setIsDragOver] = useState(false) - const handleDragOver = useCallback((e: React.DragEvent) => { // Accept drops from internal file explorer or external file drops if ( @@ -584,18 +583,11 @@ export default function TerminalPanel({ ) { // Stop here so the app-root background handler doesn't override the drop // effect to 'none' (which would suppress the drop event entirely and stop - // file paths from being inserted into the terminal). + // file paths from being inserted into the terminal). The drop indicator + // is driven globally via the capture-phase tracker, which still fires. e.stopPropagation() e.preventDefault() e.dataTransfer.dropEffect = 'copy' - setIsDragOver(true) - } - }, []) - - const handleDragLeave = useCallback((e: React.DragEvent) => { - // Only clear when leaving the container itself, not child elements - if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { - setIsDragOver(false) } }, []) @@ -603,37 +595,37 @@ export default function TerminalPanel({ (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setIsDragOver(false) - const paths: string[] = [] + const refs: DroppedRef[] = [] - // Internal file explorer drag + // Internal file explorer / search drag. A search-line drag carries the + // line number too — pasted as path:line (like a VS Code reference). const catePath = e.dataTransfer.getData('application/cate-file') if (catePath) { - paths.push(catePath) + let line: number | undefined + const lineRaw = e.dataTransfer.getData('application/cate-file-line') + if (lineRaw) { + try { + const lr = JSON.parse(lineRaw) as { path?: string; line?: number } + if (lr?.path === catePath) line = lr.line + } catch { /* ignore */ } + } + refs.push({ path: catePath, line }) } // External OS file drop — use Electron's webUtils to get real paths if (e.dataTransfer.files.length > 0) { for (const file of Array.from(e.dataTransfer.files)) { const filePath = window.electronAPI?.getPathForFile(file) - if (filePath) paths.push(filePath) + if (filePath) refs.push({ path: filePath }) } } - if (paths.length === 0) return - - // Shell-escape each path and write to terminal as space-separated text - const escaped = paths.map((p) => { - // If path contains no special shell characters, use it as-is - if (/^[a-zA-Z0-9_./:@~=-]+$/.test(p)) return p - // Otherwise, single-quote it (escaping any existing single quotes) - return "'" + p.replace(/'/g, "'\\''") + "'" - }) + if (refs.length === 0) return const entry = terminalRegistry.getEntry(panelId) if (entry) { - entry.terminal.paste(escaped.join(' ')) + entry.terminal.paste(formatTerminalPaste(refs)) } }, [panelId], @@ -688,8 +680,9 @@ export default function TerminalPanel({ ref={containerRef} className="flex-1 relative min-h-0" style={{ padding: 0, overflow: 'hidden' }} + data-filedrop="terminal" + data-filedrop-id={panelId} onDragOver={handleDragOver} - onDragLeave={handleDragLeave} onDrop={handleDrop} > {/* @@ -712,11 +705,8 @@ export default function TerminalPanel({ transformOrigin: '0 0', }} /> - {isDragOver && ( -
- Drop to paste path -
- )} + {/* File-drop indicator is rendered globally by + (this container is marked data-filedrop="terminal"). */} {/* Inline URL prompt is rendered outside this scaled box so it stays at panel scale regardless of renderScale. */} {createError && ( diff --git a/src/renderer/panels/terminalDrop.test.ts b/src/renderer/panels/terminalDrop.test.ts new file mode 100644 index 00000000..f920556f --- /dev/null +++ b/src/renderer/panels/terminalDrop.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { formatTerminalPaste } from './terminalDrop' + +describe('formatTerminalPaste', () => { + it('pastes a plain path unquoted', () => { + expect(formatTerminalPaste([{ path: '/repo/src/a.ts' }])).toBe('/repo/src/a.ts') + }) + + it('appends :line for a search-line drag', () => { + expect(formatTerminalPaste([{ path: '/repo/src/a.ts', line: 42 }])).toBe('/repo/src/a.ts:42') + }) + + it('joins multiple refs with spaces', () => { + expect(formatTerminalPaste([{ path: '/a.ts' }, { path: '/b.ts', line: 3 }])).toBe('/a.ts /b.ts:3') + }) + + it('shell-quotes paths with spaces or special characters', () => { + expect(formatTerminalPaste([{ path: '/repo/my file.ts', line: 7 }])).toBe("'/repo/my file.ts:7'") + }) + + it("escapes embedded single quotes", () => { + expect(formatTerminalPaste([{ path: "/repo/it's.ts" }])).toBe("'/repo/it'\\''s.ts'") + }) +}) diff --git a/src/renderer/panels/terminalDrop.ts b/src/renderer/panels/terminalDrop.ts new file mode 100644 index 00000000..ee88d14d --- /dev/null +++ b/src/renderer/panels/terminalDrop.ts @@ -0,0 +1,24 @@ +// ============================================================================= +// terminalDrop — pure helper for formatting dropped file references into text +// pasted at the terminal prompt. A search-line drag carries a line number, +// rendered as path:line (a VS Code-style reference). Unit-testable. +// ============================================================================= + +export interface DroppedRef { + path: string + /** 1-based line for a search-line drag; omitted for plain file drags. */ + line?: number +} + +/** Shell-escape a single path (or path:line) for safe pasting. */ +function shellEscape(p: string): string { + if (/^[a-zA-Z0-9_./:@~=-]+$/.test(p)) return p + return "'" + p.replace(/'/g, "'\\''") + "'" +} + +/** Join dropped refs into a space-separated, shell-escaped string. */ +export function formatTerminalPaste(refs: DroppedRef[]): string { + return refs + .map((r) => shellEscape(r.line ? `${r.path}:${r.line}` : r.path)) + .join(' ') +} diff --git a/src/renderer/sidebar/FileExplorer.tsx b/src/renderer/sidebar/FileExplorer.tsx index 47520e27..d77b0df7 100644 --- a/src/renderer/sidebar/FileExplorer.tsx +++ b/src/renderer/sidebar/FileExplorer.tsx @@ -6,9 +6,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import log from '../lib/logger' import { ArrowClockwise, FilePlus, FolderPlus, MagnifyingGlass, X, Folder, File } from '@phosphor-icons/react' -import type { FileTreeNode as FileTreeNodeType, FileSearchResult } from '../../shared/types' +import type { FileTreeNode as FileTreeNodeType } from '../../shared/types' import { FileTreeNode } from './FileTreeNode' -import { buildGitTreeDecorations, folderColorClass, lookupNodeDecoration, toPosixPath, type GitTree } from './gitStatusDecoration' +import { buildGitTreeDecorations, toPosixPath, type GitTree } from './gitStatusDecoration' +import { watchFsRoot } from '../lib/fsWatchManager' import { getClipboard, hasClipboard } from './fileClipboard' import { useAppStore } from '../stores/appStore' import { useDockStore } from '../stores/dockStore' @@ -73,9 +74,6 @@ export const FileExplorer: React.FC = ({ rootPath }) => { const [createRequest, setCreateRequest] = useState<{ type: 'file' | 'folder'; targetDir: string; seq: number } | null>(null) const [searchVisible, setSearchVisible] = useState(false) const [searchQuery, setSearchQuery] = useState('') - const [searchResults, setSearchResults] = useState([]) - const [searchLoading, setSearchLoading] = useState(false) - const searchSeqRef = useRef(0) const searchInputRef = useRef(null) const rootCreateInputRef = useRef(null) const lastSelectedPath = useRef(null) @@ -193,12 +191,10 @@ export const FileExplorer: React.FC = ({ rootPath }) => { // Initial load loadTree(rootPath) - // Start watcher - window.electronAPI.fsWatchStart(rootPath).catch((err) => log.warn('[file-explorer] Watch start failed:', err)) - - // Listen for events. Coalesce bursts (e.g. a build writing many files) with - // a short trailing debounce so we don't re-read the tree + re-run git status - // on every individual fs event. + // Watch via the shared refcounted manager (one underlying watcher per root, + // multiplexed) so the Explorer and the Search view's git-tree watcher don't + // tear down each other's subscription. Coalesce bursts (e.g. a build writing + // many files) with a short trailing debounce. const scheduleReload = () => { if (rootPathRef.current !== rootPath) return if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current) @@ -207,7 +203,7 @@ export const FileExplorer: React.FC = ({ rootPath }) => { if (rootPathRef.current === rootPath) loadTree(rootPath) }, 150) } - const unsubscribe = window.electronAPI.onFsWatchEvent(scheduleReload) + const releaseWatch = watchFsRoot(rootPath, scheduleReload) // Reload when the exclusions list changes so hidden/shown folders update // without a relaunch. @@ -222,9 +218,8 @@ export const FileExplorer: React.FC = ({ rootPath }) => { clearTimeout(reloadTimerRef.current) reloadTimerRef.current = null } - unsubscribe() + releaseWatch() unsubscribeSettings() - window.electronAPI?.fsWatchStop(rootPath).catch((err) => log.warn('[file-explorer] Watch stop failed:', err)) } return () => { @@ -239,34 +234,8 @@ export const FileExplorer: React.FC = ({ rootPath }) => { } }, [rootPath, loadTree]) - // --------------------------------------------------------------------------- - // Debounced file search (name + content) — runs in main process. - // --------------------------------------------------------------------------- - - useEffect(() => { - const trimmed = searchQuery.trim() - if (!trimmed || !rootPath || !window.electronAPI) { - setSearchResults([]) - setSearchLoading(false) - return - } - const seq = ++searchSeqRef.current - setSearchLoading(true) - const handle = window.setTimeout(async () => { - try { - const results = await window.electronAPI.fsSearch(rootPath, trimmed) - if (seq !== searchSeqRef.current) return - setSearchResults(results) - } catch (err) { - if (seq !== searchSeqRef.current) return - log.warn('[file-explorer] search failed:', err) - setSearchResults([]) - } finally { - if (seq === searchSeqRef.current) setSearchLoading(false) - } - }, 200) - return () => window.clearTimeout(handle) - }, [searchQuery, rootPath]) + // Inline Explorer search is a lightweight name-only tree filter ("filter on + // type"). Full content search lives in the dedicated Search view. // --------------------------------------------------------------------------- // Handlers @@ -511,7 +480,7 @@ export const FileExplorer: React.FC = ({ rootPath }) => { } e.stopPropagation() }} - placeholder="Search files" + placeholder="Filter by name" className="w-full bg-surface-5 text-primary text-xs pl-7 pr-2 py-1 rounded border border-subtle focus:border-blue-500/50 outline-none" />
@@ -527,62 +496,7 @@ export const FileExplorer: React.FC = ({ rootPath }) => { )} {/* Tree content */} - {searchQuery.trim() ? ( -
- {searchLoading && searchResults.length === 0 ? ( -
Searching…
- ) : searchResults.length === 0 ? ( -
No matches
- ) : ( - searchResults.map((r) => { - const parentRel = r.relativePath.includes('/') - ? r.relativePath.substring(0, r.relativePath.lastIndexOf('/')) - : '' - const isSel = selectedPaths.has(r.path) - const { decoration, folderKind } = lookupNodeDecoration(gitTree, r.path, r.isDirectory) - const nameColor = decoration - ? decoration.colorClass - : folderKind - ? folderColorClass(folderKind) - : 'text-primary' - return ( -
{ - handleSelect(r.path, { shift: e.shiftKey, cmd: e.metaKey || e.ctrlKey }) - }} - onDoubleClick={() => { - if (!r.isDirectory) handleFileOpen([r.path]) - }} - > -
- - {r.isDirectory ? : } - - {r.name} - {decoration && ( - - {decoration.letter} - - )} - {parentRel && {parentRel}} -
- {r.contentPreview && ( -
- {r.contentLine}: - {r.contentPreview} -
- )} -
- ) - }) - )} -
- ) : isLoading && nodes.length === 0 ? ( + {isLoading && nodes.length === 0 ? (
Loading...
@@ -639,7 +553,7 @@ export const FileExplorer: React.FC = ({ rootPath }) => { onTreeChanged={handleReload} refreshSignal={refreshSignal} visiblePaths={visiblePaths} - searchQuery="" + searchQuery={searchQuery.trim().toLowerCase()} rootPath={rootPath} createRequest={createRequest} onCreateRequestHandled={() => setCreateRequest(null)} diff --git a/src/renderer/sidebar/FileTreeNode.tsx b/src/renderer/sidebar/FileTreeNode.tsx index 5182991a..3fbf19d3 100644 --- a/src/renderer/sidebar/FileTreeNode.tsx +++ b/src/renderer/sidebar/FileTreeNode.tsx @@ -28,12 +28,12 @@ import { getClipboard, hasClipboard, setClipboard } from './fileClipboard' // Mirrors the Swift sfSymbolName mapping from FileTreeNode.swift // ----------------------------------------------------------------------------- -interface IconDef { +export interface IconDef { icon: React.ReactNode color: string } -function getFileIcon(extension: string, isDirectory: boolean, isExpanded: boolean): IconDef { +export function getFileIcon(extension: string, isDirectory: boolean, isExpanded: boolean): IconDef { if (isDirectory) { return isExpanded ? ICON_FOLDER_OPEN : ICON_FOLDER } @@ -114,6 +114,9 @@ interface FileTreeNodeProps { visiblePaths: string[] /** Lowercased search query; when non-empty, filters files and force-expands directories */ searchQuery?: string + /** Reports this node's search visibility to its parent so directories with no + * matching descendant can hide themselves. */ + onSearchVisibilityChange?: (path: string, visible: boolean) => void /** Workspace root path — used to compute relative paths for "Copy Relative Path". */ rootPath: string /** External request to create a file/folder in a specific directory */ @@ -133,12 +136,16 @@ export const FileTreeNode: React.FC = ({ refreshSignal, visiblePaths, searchQuery, + onSearchVisibilityChange, rootPath, createRequest, onCreateRequestHandled, }) => { const isSearching = !!searchQuery const [isExpanded, setIsExpanded] = useState(node.isExpanded) + // Paths of child nodes currently visible under the active search — drives + // hiding directories that have no matching descendant. + const [visibleChildren, setVisibleChildren] = useState>(new Set()) const [children, setChildren] = useState(node.children) const [isLoading, setIsLoading] = useState(false) const [isRenaming, setIsRenaming] = useState(false) @@ -171,8 +178,35 @@ export const FileTreeNode: React.FC = ({ } }, [isSearching, node.isDirectory, node.path, children.length]) - // While searching, hide files whose name doesn't match - const isHiddenBySearch = isSearching && !node.isDirectory && !node.name.toLowerCase().includes(searchQuery!) + // Search filtering. A node is visible when: not searching, OR its own name + // matches, OR (directory) it has a visible descendant. Files that don't match + // and directories with no matching descendant are hidden (VS Code + // filter-on-type). Hidden nodes use display:none (not unmount) so their + // children stay mounted and keep reporting visibility upward. + const nodeMatches = !isSearching || node.name.toLowerCase().includes(searchQuery!) + const selfVisible = nodeMatches || (node.isDirectory && visibleChildren.size > 0) + const isHiddenBySearch = isSearching && !selfVisible + + // Report this node's visibility to its parent (so empty folders collapse). + useEffect(() => { + onSearchVisibilityChange?.(node.path, selfVisible) + }, [onSearchVisibilityChange, node.path, selfVisible]) + + // Reset accumulated child visibility when the query changes so stale entries + // from a previous search don't keep a folder visible. + useEffect(() => { + setVisibleChildren(new Set()) + }, [searchQuery]) + + const handleChildVisibility = useCallback((childPath: string, visible: boolean) => { + setVisibleChildren((prev) => { + if (visible === prev.has(childPath)) return prev + const next = new Set(prev) + if (visible) next.add(childPath) + else next.delete(childPath) + return next + }) + }, []) // --------------------------------------------------------------------------- // Helpers @@ -492,10 +526,10 @@ export const FileTreeNode: React.FC = ({ // Render // --------------------------------------------------------------------------- - if (isHiddenBySearch) return null - + // Hidden nodes stay mounted (display:none) so their children keep reporting + // visibility upward — a return-null would deadlock the descendant check. return ( -
+
{/* Node row */}
= ({ refreshSignal={refreshSignal} visiblePaths={visiblePaths} searchQuery={searchQuery} + onSearchVisibilityChange={handleChildVisibility} rootPath={rootPath} createRequest={createRequest} onCreateRequestHandled={onCreateRequestHandled} diff --git a/src/renderer/sidebar/SearchResultsTree.tsx b/src/renderer/sidebar/SearchResultsTree.tsx new file mode 100644 index 00000000..d3c5089e --- /dev/null +++ b/src/renderer/sidebar/SearchResultsTree.tsx @@ -0,0 +1,397 @@ +// ============================================================================= +// SearchResultsTree — VS Code-style grouped results: collapsible files, each +// with highlighted match lines (and optional context). Supports keyboard +// navigation, open-at-line, and dismissing a match or a whole file. +// ============================================================================= + +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { CaretRight, CaretDown, X } from '@phosphor-icons/react' +import type { SearchFileResult, SearchMatchRange } from '../../shared/types' +import { getFileIcon } from './FileTreeNode' +import { trimLeading } from './searchDisplay' +import { lookupNodeDecoration, type GitTree } from './gitStatusDecoration' +import { useSearchStore, lineKey } from '../stores/searchStore' +import { useAppStore } from '../stores/appStore' +import { openFileAsPanel } from '../lib/fileRouting' +import { setPendingReveal } from '../lib/editorReveal' + +// Uniform row height (px). Both the file-header and code-line rows are forced to +// this height so the windowed (virtualized) list can map scrollTop <-> row index +// with trivial arithmetic — no per-row measurement needed. +const ROW_H = 22 +// Extra rows rendered above/below the viewport so fast scrolling never flashes +// blank before the next frame. +const OVERSCAN = 8 + +/** Render a line's text with its match ranges highlighted. */ +const Highlighted: React.FC<{ text: string; ranges: SearchMatchRange[] }> = ({ text, ranges }) => { + if (ranges.length === 0) return <>{text} + const parts: React.ReactNode[] = [] + let cursor = 0 + ranges.forEach((r, i) => { + if (r.start > cursor) parts.push({text.slice(cursor, r.start)}) + parts.push( + + {text.slice(r.start, r.end)} + , + ) + cursor = Math.max(cursor, r.end) + }) + if (cursor < text.length) parts.push({text.slice(cursor)}) + return <>{parts} +} + +const baseName = (p: string): string => { + const i = p.lastIndexOf('/') + return i === -1 ? p : p.slice(i + 1) +} +const dirName = (p: string): string => { + const i = p.lastIndexOf('/') + return i === -1 ? '' : p.slice(0, i) +} +const extOf = (name: string): string => { + const i = name.lastIndexOf('.') + return i === -1 ? '' : name.slice(i + 1) +} + +/** Populate a drag with the same MIME types the Explorer uses, so canvas / dock + * / terminal / agent drop targets all accept it. For a line drag, also carry + * the line + column so canvas/dock drops can open at the match. */ +function setFileDrag(e: React.DragEvent, path: string, line?: number, column?: number): void { + e.dataTransfer.setData('application/cate-file', path) + e.dataTransfer.setData('application/cate-files', JSON.stringify([path])) + e.dataTransfer.setData('text/plain', path) + if (line != null) { + e.dataTransfer.setData('application/cate-file-line', JSON.stringify({ path, line, column: column ?? 1 })) + } + e.dataTransfer.effectAllowed = 'copy' +} + +type Row = + | { kind: 'file'; file: SearchFileResult } + | { kind: 'line'; file: SearchFileResult; lineIdx: number } + +interface Props { + /** Visible files (already filtered for dismissed files by the caller). */ + files: SearchFileResult[] + /** Git decorations for the repo, so file rows tint like the Explorer. */ + git?: GitTree +} + +export const SearchResultsTree: React.FC = ({ files, git }) => { + const collapsed = useSearchStore((s) => s.collapsed) + const dismissedLines = useSearchStore((s) => s.dismissedLines) + const toggleCollapse = useSearchStore((s) => s.toggleCollapse) + const dismissFile = useSearchStore((s) => s.dismissFile) + const dismissLine = useSearchStore((s) => s.dismissLine) + + const [selected, setSelected] = useState(0) + // Full-line preview shown on row hover (portal tooltip; no layout shift). + const [hover, setHover] = useState<{ + text: string + ranges: SearchMatchRange[] + top: number + left: number + } | null>(null) + + // --- Virtualization state. Only the rows intersecting the viewport (plus a + // small overscan) are mounted, so a query with thousands of matches stays + // responsive. Default the viewport to a plausible height so the very first + // render is already windowed (avoids momentarily committing every row). + const containerRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const [viewportH, setViewportH] = useState(600) + const rafRef = useRef(null) + + // Build the flat list of visible rows (file headers + non-dismissed match lines). + const rows = useMemo(() => { + const out: Row[] = [] + for (const file of files) { + out.push({ kind: 'file', file }) + if (collapsed.has(file.path)) continue + file.lines.forEach((ln, lineIdx) => { + if (dismissedLines.has(lineKey(file.path, ln.line))) return + out.push({ kind: 'line', file, lineIdx }) + }) + } + return out + }, [files, collapsed, dismissedLines]) + + // Per-file count of matches still visible (excludes dismissed match lines). + const visibleCount = useMemo(() => { + const m = new Map() + for (const file of files) { + let c = 0 + for (const ln of file.lines) { + if (dismissedLines.has(lineKey(file.path, ln.line))) continue + c += ln.ranges.length + } + m.set(file.path, c) + } + return m + }, [files, dismissedLines]) + + // Keep the selected index within bounds as rows change. + useEffect(() => { + if (selected >= rows.length) setSelected(Math.max(0, rows.length - 1)) + }, [rows.length, selected]) + + // Track the scroll viewport height (measured before paint so the first frame + // is already windowed) and react to sidebar resizes. + useLayoutEffect(() => { + const el = containerRef.current + if (!el) return + const measure = (): void => setViewportH(el.clientHeight) + measure() + const ro = new ResizeObserver(measure) + ro.observe(el) + return () => { + ro.disconnect() + if (rafRef.current != null) cancelAnimationFrame(rafRef.current) + } + }, []) + + // Keep the keyboard-selected row visible: since off-screen rows aren't mounted, + // we scroll by arithmetic instead of relying on the element's scrollIntoView. + useEffect(() => { + const el = containerRef.current + if (!el || rows.length === 0) return + const top = selected * ROW_H + const bottom = top + ROW_H + if (top < el.scrollTop) el.scrollTop = top + else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight + }, [selected, rows.length]) + + // rAF-throttle scroll updates to one windowing recompute per frame. + const onScroll = (): void => { + if (rafRef.current != null) return + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + const el = containerRef.current + if (el) setScrollTop(el.scrollTop) + }) + } + + const total = rows.length + const startIdx = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN) + const endIdx = Math.min(total, Math.ceil((scrollTop + viewportH) / ROW_H) + OVERSCAN) + const visibleRows = rows.slice(startIdx, endIdx) + + const openLine = (file: SearchFileResult, lineIdx: number): void => { + const wsId = useAppStore.getState().selectedWorkspaceId + if (!wsId) return + const ln = file.lines[lineIdx] + const column = (ln.ranges[0]?.start ?? 0) + 1 + const panelId = openFileAsPanel(wsId, file.path, undefined, { target: 'dock', zone: 'center' }) + setPendingReveal(panelId, { line: ln.line, column }) + } + + const activate = (row: Row): void => { + if (row.kind === 'file') toggleCollapse(row.file.path) + else openLine(row.file, row.lineIdx) + } + + const onKeyDown = (e: React.KeyboardEvent): void => { + if (rows.length === 0) return + switch (e.key) { + case 'ArrowDown': + e.preventDefault() + setSelected((i) => Math.min(rows.length - 1, i + 1)) + break + case 'ArrowUp': + e.preventDefault() + setSelected((i) => Math.max(0, i - 1)) + break + case 'ArrowRight': { + const row = rows[selected] + if (row?.kind === 'file' && collapsed.has(row.file.path)) { + e.preventDefault() + toggleCollapse(row.file.path) + } + break + } + case 'ArrowLeft': { + const row = rows[selected] + if (row?.kind === 'file' && !collapsed.has(row.file.path)) { + e.preventDefault() + toggleCollapse(row.file.path) + } + break + } + case 'Enter': { + const row = rows[selected] + if (row) { + e.preventDefault() + activate(row) + } + break + } + } + } + + return ( +
setHover(null)} + > + {/* Tall spacer = full scroll height; the windowed slice below is absolutely + positioned and translated down to the first visible row. */} +
+
+ {visibleRows.map((row, i) => { + const idx = startIdx + i + const isSel = selected === idx + if (row.kind === 'file') { + const file = row.file + const isCollapsed = collapsed.has(file.path) + const dir = dirName(file.relativePath) + const count = visibleCount.get(file.path) ?? file.matchCount + const fileIcon = getFileIcon(extOf(file.relativePath), false, false) + // Tint the file name by git status, exactly like the Explorer. + const { decoration } = lookupNodeDecoration(git, file.path, false) + const nameColor = decoration ? decoration.colorClass : 'text-primary' + return ( +
{ + setSelected(idx) + toggleCollapse(file.path) + }} + onMouseDown={() => setHover(null)} + title={file.relativePath} + draggable + onDragStart={(e) => { + setHover(null) + setFileDrag(e, file.path) + }} + onDragEnd={() => setHover(null)} + > + + {isCollapsed ? : } + + + {fileIcon.icon} + + + {baseName(file.relativePath)} + + {dir && {dir}} + + + {count} + + + +
+ ) + } + + const { file, lineIdx } = row + const ln = file.lines[lineIdx] + const isContext = ln.ranges.length === 0 + // Show the line with only leading whitespace trimmed; the row + // truncates on the RIGHT (CSS), like VS Code — no left "…" clipping, + // so leading context stays readable. Full line still in the tooltip. + const full = trimLeading(ln.text, ln.ranges) + return ( +
{ + const r = e.currentTarget.getBoundingClientRect() + setHover({ text: full.text, ranges: full.ranges, top: r.bottom, left: r.left }) + }} + onMouseLeave={() => setHover(null)} + onClick={() => { + setSelected(idx) + if (!isContext) openLine(file, lineIdx) + }} + onMouseDown={() => setHover(null)} + draggable + onDragStart={(e) => { + setHover(null) + setFileDrag(e, file.path, ln.line, (ln.ranges[0]?.start ?? 0) + 1) + }} + onDragEnd={() => setHover(null)} + > + + :{ln.line} + + + + + {!isContext && ( + + )} +
+ ) + })} +
+
+ {hover && + createPortal( +
+ +
, + document.body, + )} +
+ ) +} diff --git a/src/renderer/sidebar/SearchView.tsx b/src/renderer/sidebar/SearchView.tsx new file mode 100644 index 00000000..3e0f0a64 --- /dev/null +++ b/src/renderer/sidebar/SearchView.tsx @@ -0,0 +1,293 @@ +// ============================================================================= +// SearchView — dedicated activity-bar view for project-wide content search, +// modelled on VS Code's Search view. Streams ripgrep results into searchStore. +// ============================================================================= + +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { MagnifyingGlass, DotsThree, Gear, Eraser } from '@phosphor-icons/react' +import { SidebarSectionHeader, SidebarHeaderButton } from './SidebarSectionHeader' +import { SearchResultsTree } from './SearchResultsTree' +import { Tooltip } from './Tooltip' +import { useGitTree } from './useGitTree' +import { useSearchStore, lineKey } from '../stores/searchStore' +import { ensureSearchSubscriptions } from '../stores/searchIpc' +import log from '../lib/logger' + +const DEBOUNCE_MS = 250 +let searchSeq = 0 + +/** Split a comma-separated glob field into trimmed, non-empty patterns. */ +function splitGlobs(value: string): string[] { + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} + +interface ToggleBtnProps { + active: boolean + onClick: () => void + title: string + children: React.ReactNode +} +const ToggleBtn: React.FC = ({ active, onClick, title, children }) => ( + + + +) + +export const SearchView: React.FC<{ rootPath: string }> = ({ rootPath }) => { + const query = useSearchStore((s) => s.query) + const isRegex = useSearchStore((s) => s.isRegex) + const matchCase = useSearchStore((s) => s.matchCase) + const wholeWord = useSearchStore((s) => s.wholeWord) + const includes = useSearchStore((s) => s.includes) + const excludes = useSearchStore((s) => s.excludes) + const respectIgnore = useSearchStore((s) => s.respectIgnore) + const optionsExpanded = useSearchStore((s) => s.optionsExpanded) + + const status = useSearchStore((s) => s.status) + const files = useSearchStore((s) => s.files) + const truncated = useSearchStore((s) => s.truncated) + const error = useSearchStore((s) => s.error) + const dismissedFiles = useSearchStore((s) => s.dismissedFiles) + const dismissedLines = useSearchStore((s) => s.dismissedLines) + const focusToken = useSearchStore((s) => s.focusToken) + + const setQuery = useSearchStore((s) => s.setQuery) + const setOptions = useSearchStore((s) => s.setOptions) + const toggleOptionsExpanded = useSearchStore((s) => s.toggleOptionsExpanded) + + const inputRef = useRef(null) + // Placeholders show only while a field is focused (hidden at rest). + const [focusedField, setFocusedField] = useState<'query' | 'include' | 'exclude' | null>(null) + // Git decorations so result file rows tint like the Explorer. + const gitTree = useGitTree(rootPath) + + // Ensure window-level result subscriptions exist (idempotent; persists across + // mount/unmount so batches arriving while the view is hidden aren't lost). + useEffect(() => { + ensureSearchSubscriptions() + }, []) + + // Focus the input when something requests it (e.g. Cmd+Shift+F). + useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + }, [focusToken]) + + // Debounced search trigger. The searchId is set in the store BEFORE invoking + // so streamed batches are never dropped as "stale". Skips re-running an + // identical search (same query + options + root) — e.g. when this view is + // remounted after switching sidebar tabs — since results persist in the store. + useEffect(() => { + const trimmed = query.trim() + if (!trimmed || !rootPath) { + useSearchStore.getState().clearResults() + window.electronAPI.searchCancel().catch(() => { /* noop */ }) + return + } + const key = JSON.stringify([ + trimmed, isRegex, matchCase, wholeWord, includes, excludes, respectIgnore, rootPath, + ]) + if (key === useSearchStore.getState().lastQueryKey) return + const handle = window.setTimeout(() => { + const searchId = `search-${++searchSeq}` + useSearchStore.getState().beginSearch(searchId, key) + window.electronAPI + .searchStart(rootPath, searchId, { + query: trimmed, + isRegex, + matchCase, + wholeWord, + includes: splitGlobs(includes), + excludes: splitGlobs(excludes), + respectIgnore, + }) + .catch((err) => log.warn('[search] start failed:', err)) + }, DEBOUNCE_MS) + return () => window.clearTimeout(handle) + }, [query, isRegex, matchCase, wholeWord, includes, excludes, respectIgnore, rootPath]) + + // Visible files + accurate counts (excluding dismissed files / lines). + const { visibleFiles, matchCount, fileCount } = useMemo(() => { + const vf = files.filter((f) => !dismissedFiles.has(f.path)) + let matches = 0 + let fileCnt = 0 + for (const f of vf) { + let fileMatches = 0 + for (const ln of f.lines) { + if (dismissedLines.has(lineKey(f.path, ln.line))) continue + fileMatches += ln.ranges.length + } + if (fileMatches > 0) { + matches += fileMatches + fileCnt += 1 + } + } + return { visibleFiles: vf, matchCount: matches, fileCount: fileCnt } + }, [files, dismissedFiles, dismissedLines]) + + const hasQuery = query.trim().length > 0 + + // Reset the search: clear the query, results, and any in-flight run. + const clearSearch = (): void => { + setQuery('') + useSearchStore.getState().clearResults() + window.electronAPI.searchCancel().catch(() => { /* noop */ }) + inputRef.current?.focus() + } + + return ( +
+ + + + + + } + /> + + {/* Query input + match-mode toggles */} +
+
+
+ + setQuery(e.target.value)} + onFocus={() => setFocusedField('query')} + onBlur={() => setFocusedField(null)} + onKeyDown={(e) => { + if (e.key === 'Escape') setQuery('') + e.stopPropagation() + }} + placeholder={focusedField === 'query' ? 'Search' : ''} + spellCheck={false} + className="w-full bg-surface-5 text-primary text-xs pl-7 pr-14 py-1 rounded border border-subtle focus:border-blue-500/50 outline-none" + /> +
+ setOptions({ matchCase: !matchCase })} title="Match Case"> + Aa + + setOptions({ wholeWord: !wholeWord })} title="Match Whole Word"> + ab + + setOptions({ isRegex: !isRegex })} title="Use Regular Expression"> + .* + +
+
+ {/* VS Code-style "..." toggle that reveals the include/exclude details. */} + + + +
+ + {/* Expandable include / exclude (VS Code-style) */} + {optionsExpanded && ( +
+
+ files to include + setOptions({ includes: e.target.value })} + onFocus={() => setFocusedField('include')} + onBlur={() => setFocusedField(null)} + onKeyDown={(e) => e.stopPropagation()} + placeholder={focusedField === 'include' ? 'e.g. src/**, *.ts' : ''} + spellCheck={false} + className="w-full bg-surface-5 text-primary text-[11px] px-2 py-1 rounded border border-subtle focus:border-blue-500/50 outline-none" + /> +
+
+ files to exclude +
+ setOptions({ excludes: e.target.value })} + onFocus={() => setFocusedField('exclude')} + onBlur={() => setFocusedField(null)} + onKeyDown={(e) => e.stopPropagation()} + placeholder={focusedField === 'exclude' ? 'e.g. *.lock, dist/**' : ''} + spellCheck={false} + className="w-full bg-surface-5 text-primary text-[11px] pl-2 pr-7 py-1 rounded border border-subtle focus:border-blue-500/50 outline-none" + /> +
+ setOptions({ respectIgnore: !respectIgnore })} + title="Use Exclude Settings and Ignore Files" + > + + +
+
+
+
+ )} +
+ + {/* Count / status line */} + {hasQuery && ( +
+ {error ? ( + {error} + ) : status === 'searching' && matchCount === 0 ? ( + Searching… + ) : matchCount === 0 ? ( + No results + ) : ( + + {matchCount} {matchCount === 1 ? 'result' : 'results'} in {fileCount}{' '} + {fileCount === 1 ? 'file' : 'files'} + {truncated && ' (truncated)'} + + )} +
+ )} + + {/* Results */} + {hasQuery && !error && visibleFiles.length > 0 ? ( + + ) : !hasQuery ? ( +
+ Search across files in this folder. +
+ ) : ( +
+ )} +
+ ) +} diff --git a/src/renderer/sidebar/Sidebar.tsx b/src/renderer/sidebar/Sidebar.tsx index 76a04820..a57dd58f 100644 --- a/src/renderer/sidebar/Sidebar.tsx +++ b/src/renderer/sidebar/Sidebar.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { ProjectList } from './ProjectList' import { FileExplorer } from './FileExplorer' +import { SearchView } from './SearchView' import { SourceControlView } from './SourceControlView' import { ParallelWorkTab } from './ParallelWorkTab' import { useAppStore } from '../stores/appStore' @@ -24,6 +25,7 @@ import pkg from '../../../package.json' const VIEW_META: Record = { workspaces: { icon: Stack, title: 'Workspaces' }, explorer: { icon: FolderOpen, title: 'Explorer' }, + search: { icon: MagnifyingGlass, title: 'Search' }, git: { icon: GitBranch, title: 'Source Control' }, parallelWork: { icon: ArrowsSplit, title: 'Parallel Work' }, } @@ -62,6 +64,8 @@ const SidebarViewContent: React.FC<{ view: SidebarView; rootPath: string }> = ({
) + case 'search': + return case 'git': return case 'parallelWork': @@ -313,14 +317,8 @@ const ActivityBarSidebar: React.FC = ({ side, defaultWi
{side === 'left' && (
- + {/* The standalone ⌘K search icon was removed now that the dedicated + Search view exists; ⌘K still opens the command palette via keyboard. */}