diff --git a/app/frontend/src/components/GpuEditor.tsx b/app/frontend/src/components/GpuEditor.tsx index 56ab814..d4dbea2 100644 --- a/app/frontend/src/components/GpuEditor.tsx +++ b/app/frontend/src/components/GpuEditor.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react' +import React, { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' import { invoke } from '../lib/ipc' import { git, parseChangedLines } from '../lib/git' @@ -60,6 +60,12 @@ export interface GpuEditorHandle { goToLine: (line: number) => void expandSmartSelect: () => void shrinkSmartSelect: () => void + moveLineUp: () => void + moveLineDown: () => void + copyLineUp: () => void + copyLineDown: () => void + formatDocument: (newContent: string) => Promise + getWordAtCursor: () => Promise } interface Props { @@ -87,6 +93,8 @@ interface Props { onLineCountChange?: (count: number) => void onDirtyChange?: (dirty: boolean) => void onEolChange?: (eol: 'LF' | 'CRLF') => void + onGoToDefinition?: () => void + onGoToReferences?: () => void } // Fallback palette used until a theme-derived `colors` prop arrives — @@ -467,6 +475,7 @@ const GpuEditor = forwardRef(function GpuEditor({ filePath, fontSize = 13, colors, readOnly = false, minimap = false, indentGuides = false, wordWrap = false, gotoLine, gotoToken, viewKey, showHeader = true, diagnostics, gitGutter = true, onCursorChange, onLineCountChange, onDirtyChange, onEolChange, + onGoToDefinition, onGoToReferences, }, ref) { const containerRef = useRef(null) const canvasRef = useRef(null) @@ -1750,6 +1759,124 @@ const GpuEditor = forwardRef(function GpuEditor({ draw() }, [closeCompletions, draw, ensureCursorVisible, ensureLine, fetchVisible]) + // Returns the union of all line indices touched by any cursor or selection. + function touchedLines(cursors: Cursor[]): [number, number] { + let first = Infinity, last = -Infinity + for (const c of cursors) { + const r = rangeOf(c) + if (r) { first = Math.min(first, r[0]); last = Math.max(last, r[2]) } + else { first = Math.min(first, c.line); last = Math.max(last, c.line) } + } + return [first, last] + } + + // Shared post-edit housekeeping for line move/copy operations. + const applyLineEdit = useCallback(async ( + edit: { startLine: number; startCol: number; endLine: number; endCol: number; text: string }, + newCursors: Cursor[], + ) => { + if (readOnlyRef.current) return + const resp = await invoke<{ version: number; lineCount: number; dirtyStart: number; dirtyEnd: number }>('editor.edit', { + bufferId: bufferIdRef.current, edits: [edit], + }) + const prevLineCount = lineCountRef.current + versionRef.current = resp.version + lineCountRef.current = resp.lineCount + if (resp.lineCount !== prevLineCount) foldedRangesRef.current.clear() + shiftLineSet(pinnedLinesRef.current, prevLineCount, resp.lineCount, resp.dirtyStart, resp.dirtyEnd) + shiftLineSet(gitChangedLinesRef.current, prevLineCount, resp.lineCount, resp.dirtyStart, resp.dirtyEnd) + markLinesChanged(gitChangedLinesRef.current, resp.dirtyStart, resp.dirtyEnd, resp.lineCount) + invalidateDirtyLines(prevLineCount, resp.lineCount, resp.dirtyStart, resp.dirtyEnd) + cursorsRef.current = newCursors + cursorVisibleRef.current = true + setStatus('●') + notifyDirty(true) + onLineCountChangeRef.current?.(lineCountRef.current) + notifyCursor() + ensureCursorVisible() + fetchVisible() + draw() + void updateBracketMatch() + if (findOpenRef.current) void runSearchRef.current(false) + }, [draw, ensureCursorVisible, fetchVisible, invalidateDirtyLines, notifyCursor, notifyDirty, updateBracketMatch]) + + // Alt+Up — swap the block of lines touched by any cursor/selection with the line above. + const moveLineUp = useCallback(async () => { + closeCompletions() + const cursors = cursorsRef.current + const [firstLine, lastLine] = touchedLines(cursors) + if (firstLine === 0) return + const lineTexts: string[] = [] + for (let ln = firstLine - 1; ln <= lastLine; ln++) { + lineTexts.push((await ensureLine(ln))?.text ?? '') + } + const aboveText = lineTexts[0] + const movedTexts = lineTexts.slice(1) + const newText = movedTexts.join('\n') + '\n' + aboveText + const endCol = lineTexts[lineTexts.length - 1].length + const newCursors = cursors.map(c => ({ + ...c, + line: c.line - 1, + ...(c.anchorLine !== undefined ? { anchorLine: c.anchorLine - 1 } : {}), + })) + await applyLineEdit({ startLine: firstLine - 1, startCol: 0, endLine: lastLine, endCol, text: newText }, newCursors) + }, [applyLineEdit, closeCompletions, ensureLine]) + + // Alt+Down — swap the block of lines touched by any cursor/selection with the line below. + const moveLineDown = useCallback(async () => { + closeCompletions() + const cursors = cursorsRef.current + const [firstLine, lastLine] = touchedLines(cursors) + if (lastLine >= lineCountRef.current - 1) return + const lineTexts: string[] = [] + for (let ln = firstLine; ln <= lastLine + 1; ln++) { + lineTexts.push((await ensureLine(ln))?.text ?? '') + } + const belowText = lineTexts[lineTexts.length - 1] + const movedTexts = lineTexts.slice(0, -1) + const newText = belowText + '\n' + movedTexts.join('\n') + const endCol = belowText.length + const newCursors = cursors.map(c => ({ + ...c, + line: c.line + 1, + ...(c.anchorLine !== undefined ? { anchorLine: c.anchorLine + 1 } : {}), + })) + await applyLineEdit({ startLine: firstLine, startCol: 0, endLine: lastLine + 1, endCol, text: newText }, newCursors) + }, [applyLineEdit, closeCompletions, ensureLine]) + + // Shift+Alt+Up — insert a duplicate of the touched lines immediately above; cursor follows original content down. + const copyLineUp = useCallback(async () => { + closeCompletions() + const cursors = cursorsRef.current + const [firstLine, lastLine] = touchedLines(cursors) + const lineTexts: string[] = [] + for (let ln = firstLine; ln <= lastLine; ln++) { + lineTexts.push((await ensureLine(ln))?.text ?? '') + } + const blockText = lineTexts.join('\n') + '\n' + const count = lastLine - firstLine + 1 + const newCursors = cursors.map(c => ({ + ...c, + line: c.line + count, + ...(c.anchorLine !== undefined ? { anchorLine: c.anchorLine + count } : {}), + })) + await applyLineEdit({ startLine: firstLine, startCol: 0, endLine: firstLine, endCol: 0, text: blockText }, newCursors) + }, [applyLineEdit, closeCompletions, ensureLine]) + + // Shift+Alt+Down — insert a duplicate of the touched lines immediately below; cursor stays on original. + const copyLineDown = useCallback(async () => { + closeCompletions() + const cursors = cursorsRef.current + const [firstLine, lastLine] = touchedLines(cursors) + const lineTexts: string[] = [] + for (let ln = firstLine; ln <= lastLine; ln++) { + lineTexts.push((await ensureLine(ln))?.text ?? '') + } + const blockText = '\n' + lineTexts.join('\n') + const endCol = lineTexts[lineTexts.length - 1].length + await applyLineEdit({ startLine: lastLine, startCol: endCol, endLine: lastLine, endCol, text: blockText }, cursors.slice()) + }, [applyLineEdit, closeCompletions, ensureLine]) + // ── Init ──────────────────────────────────────────────────────────────────── useEffect(() => { @@ -1968,9 +2095,13 @@ const GpuEditor = forwardRef(function GpuEditor({ switch (e.key) { case 'ArrowUp': if (e.ctrlKey && e.altKey) { e.preventDefault(); void addCursorVertical(-1); return } + if (e.altKey && e.shiftKey) { e.preventDefault(); void copyLineUp(); return } + if (e.altKey) { e.preventDefault(); void moveLineUp(); return } e.preventDefault(); void moveCursor(-1, 0, shift); return case 'ArrowDown': if (e.ctrlKey && e.altKey) { e.preventDefault(); void addCursorVertical(1); return } + if (e.altKey && e.shiftKey) { e.preventDefault(); void copyLineDown(); return } + if (e.altKey) { e.preventDefault(); void moveLineDown(); return } e.preventDefault(); void moveCursor(1, 0, shift); return case 'ArrowLeft': if (e.shiftKey && e.altKey) { e.preventDefault(); shrinkSmartSelect(); return } @@ -2034,6 +2165,11 @@ const GpuEditor = forwardRef(function GpuEditor({ void copySelection().then(() => deleteForward()) } return + case 'F12': + e.preventDefault() + if (shift) onGoToReferences?.() + else onGoToDefinition?.() + return default: if (!e.ctrlKey && !e.metaKey && !e.altKey && (AUTO_CLOSE_PAIRS[e.key] !== undefined || AUTO_CLOSE_CLOSERS.has(e.key))) { e.preventDefault() @@ -2041,7 +2177,7 @@ const GpuEditor = forwardRef(function GpuEditor({ } return } - }, [acceptCompletion, addCursorVertical, advanceSnippetStop, closeCompletions, closeFind, copySelection, deleteBackward, deleteForward, draw, expandSmartSelect, handleEnter, handleTypedChar, insertText, moveCursor, moveCursorsTo, openFind, redo, requestCompletions, save, selectAll, shrinkSmartSelect, undo]) + }, [acceptCompletion, addCursorVertical, advanceSnippetStop, closeCompletions, closeFind, copyLineDown, copyLineUp, copySelection, deleteBackward, deleteForward, draw, expandSmartSelect, handleEnter, handleTypedChar, insertText, moveCursor, moveCursorsTo, moveLineDown, moveLineUp, onGoToDefinition, onGoToReferences, openFind, redo, requestCompletions, save, selectAll, shrinkSmartSelect, undo]) const onInput = useCallback((e: React.FormEvent) => { const ta = e.currentTarget @@ -2273,7 +2409,35 @@ const GpuEditor = forwardRef(function GpuEditor({ }, expandSmartSelect: () => { void expandSmartSelect() }, shrinkSmartSelect, - }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect]) + moveLineUp: () => { void moveLineUp() }, + moveLineDown: () => { void moveLineDown() }, + copyLineUp: () => { void copyLineUp() }, + copyLineDown: () => { void copyLineDown() }, + formatDocument: async (newContent: string) => { + const lineCount = lineCountRef.current + if (lineCount === 0) return + const cursor = cursorsRef.current[0] + await applyRawEdits([{ startLine: 0, startCol: 0, endLine: lineCount - 1, endCol: 999999, text: newContent }]) + if (cursor) { + const clampedLine = Math.min(cursor.line, lineCountRef.current - 1) + cursorsRef.current = [{ line: clampedLine, col: cursor.col }] + void ensureCursorVisible() + draw() + } + }, + getWordAtCursor: async () => { + const { line, col } = cursorsRef.current[0] + const data = await ensureLine(line) + if (!data) return null + const text = data.text + let start = col + let end = col + while (start > 0 && /[\w$]/.test(text[start - 1])) start-- + while (end < text.length && /[\w$]/.test(text[end])) end++ + if (start === end) return null + return text.slice(start, end) + }, + }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect, moveLineUp, moveLineDown, copyLineUp, copyLineDown, applyRawEdits, ensureCursorVisible, draw, ensureLine]) return (
diff --git a/app/frontend/src/fullscreen/FullscreenIDE.tsx b/app/frontend/src/fullscreen/FullscreenIDE.tsx index b2e8fa8..57afa90 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { ChevronRight, PanelLeft, PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { invoke, on, offAll, b64ToText, textToB64 } from '../lib/ipc' import FileExplorer, { FileNode } from './FileExplorer' @@ -130,6 +130,16 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW // ── drag-and-drop ───────────────────────────────────────────────────────────── const [draggedTab, setDraggedTab] = useState(null) + // ── format document toast ───────────────────────────────────────────────────── + const [formatMsg, setFormatMsg] = useState(null) + + // ── goto definition / references results panel ──────────────────────────────── + const [gotoResults, setGotoResults] = useState<{ + kind: 'definition' | 'references' + symbol: string + results: Array<{ path: string; line: number; text: string }> + } | null>(null) + // ── status bar (per-panel — each GpuEditor reports its own state) ───────────── const [leftStatus, setLeftStatus] = useState(INITIAL_PANE_STATUS) const [rightStatus, setRightStatus] = useState(INITIAL_PANE_STATUS) @@ -366,6 +376,53 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW }) }, []) + // ── goto definition / references ───────────────────────────────────────────── + const navigateToResult = useCallback(async (relPath: string, line: number) => { + const cwdSlash = cwd.replace(/\\/g, '/') + const fullPath = relPath.startsWith('/') || /^[A-Za-z]:/.test(relPath) + ? relPath.replace(/\\/g, '/') + : `${cwdSlash}/${relPath}` + const name = fullPath.split('/').pop() ?? fullPath + const dot = name.lastIndexOf('.') + const ext = dot > 0 ? name.slice(dot + 1) : '' + const token = Date.now() + setGotoResults(null) + setPendingGotoLine({ path: fullPath, line, token }) + await openFile({ name, path: fullPath, isDir: false, ext }) + const handle = focusedPanelRef.current === 'left' ? leftEditorRef.current : rightEditorRef.current + handle?.goToLine(line) + }, [cwd, openFile]) + + const handleGoToDefinition = useCallback(async () => { + const handle = focusedPanelRef.current === 'left' ? leftEditorRef.current : rightEditorRef.current + if (!handle) return + const symbol = await handle.getWordAtCursor() + if (!symbol) return + try { + const { results } = await invoke<{ results: Array<{ path: string; line: number; text: string }> }>( + 'search.definition', { path: cwd, symbol } + ) + if (results.length === 1) { + void navigateToResult(results[0].path, results[0].line) + } else { + setGotoResults({ kind: 'definition', symbol, results }) + } + } catch { /* rg not available */ } + }, [cwd, navigateToResult]) + + const handleGoToReferences = useCallback(async () => { + const handle = focusedPanelRef.current === 'left' ? leftEditorRef.current : rightEditorRef.current + if (!handle) return + const symbol = await handle.getWordAtCursor() + if (!symbol) return + try { + const { results } = await invoke<{ results: Array<{ path: string; line: number; text: string }> }>( + 'search.references', { path: cwd, symbol } + ) + setGotoResults({ kind: 'references', symbol, results }) + } catch { /* rg not available */ } + }, [cwd]) + // Auto-close tabs for files removed from disk, detected via the native file // watcher's 'fs:changed' dir-change events (re-list each affected directory // and drop any open file no longer present). @@ -467,6 +524,68 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW const ref = panel === 'left' ? leftEditorRef.current : rightEditorRef.current await ref?.save() }, []) + const formatDocumentAction = useCallback(async () => { + const panel = focusedPanelRef.current + const handle = panel === 'left' ? leftEditorRef.current : rightEditorRef.current + const filePath = panel === 'left' ? leftActiveRef.current : rightActiveRef.current + if (!handle || !filePath) return + + const ext = filePath.split('.').pop()?.toLowerCase() ?? '' + + if (ext === 'json') { + const raw = await invoke<{ content: string }>('fs.readfile', { path: filePath }) + const text = b64ToText(raw.content) + try { + const formatted = JSON.stringify(JSON.parse(text), null, 2) + await handle.formatDocument(formatted) + } catch { + setFormatMsg('JSON parse error - file not formatted') + setTimeout(() => setFormatMsg(null), 3000) + } + return + } + + type FormatterSpec = { cmd: string; args: (path: string) => string[] } + const FORMATTERS: Record = { + ts: { cmd: 'prettier', args: p => ['--write', p] }, + tsx: { cmd: 'prettier', args: p => ['--write', p] }, + js: { cmd: 'prettier', args: p => ['--write', p] }, + jsx: { cmd: 'prettier', args: p => ['--write', p] }, + css: { cmd: 'prettier', args: p => ['--write', p] }, + html: { cmd: 'prettier', args: p => ['--write', p] }, + md: { cmd: 'prettier', args: p => ['--write', p] }, + rs: { cmd: 'rustfmt', args: p => [p] }, + go: { cmd: 'gofmt', args: p => ['-w', p] }, + py: { cmd: 'black', args: p => [p] }, + c: { cmd: 'clang-format', args: p => ['-i', p] }, + cpp: { cmd: 'clang-format', args: p => ['-i', p] }, + h: { cmd: 'clang-format', args: p => ['-i', p] }, + } + + const spec = FORMATTERS[ext] + if (!spec) { + const lang = langFromExt(ext) + setFormatMsg(`No formatter available for ${lang === 'plaintext' ? ext || 'this file type' : lang}`) + setTimeout(() => setFormatMsg(null), 3000) + return + } + + await handle.save() + const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : cwd + const output = await invoke('shell.exec', { cmd: spec.cmd, dir, args: spec.args(filePath) }) + const errorSignals = ['error', 'not found', 'command not found', 'is not recognized', 'No such file'] + const hasError = errorSignals.some(s => output.toLowerCase().includes(s.toLowerCase())) + if (hasError) { + const msg = output.trim().split('\n')[0]?.slice(0, 80) ?? 'Formatter error' + setFormatMsg(msg) + setTimeout(() => setFormatMsg(null), 4000) + return + } + + const raw = await invoke<{ content: string }>('fs.readfile', { path: filePath }) + const formatted = b64ToText(raw.content) + await handle.formatDocument(formatted) + }, [cwd]) useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -589,6 +708,8 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW onDirtyChange={dirty => setOpenFiles(prev => prev.map(f => f.path === fileObj.path ? { ...f, dirty, pinned: dirty ? true : f.pinned } : f ))} + onGoToDefinition={handleGoToDefinition} + onGoToReferences={handleGoToReferences} /> ) } @@ -759,6 +880,44 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW )}
{/* end ide-panes-row */} + {gotoResults && ( +
+
+ + {gotoResults.kind === 'definition' ? 'Definition' : 'References'} of '{gotoResults.symbol}' + {gotoResults.results.length > 0 && ( + ({gotoResults.results.length}) + )} + + +
+ {gotoResults.results.length === 0 ? ( +
+ No {gotoResults.kind} found for '{gotoResults.symbol}' +
+ ) : ( +
+ {gotoResults.results.map((r, i) => ( + + ))} +
+ )} +
+ )} + {/* Status bar — spans full width below both panes */}
@@ -781,6 +940,10 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW )}
+ + {formatMsg && ( +
{formatMsg}
+ )} ) @@ -807,9 +970,16 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW else if (cmd === 'editor.action.toggleMinimap') setMinimapEnabled(v => !v) else if (cmd === 'editor.action.smartSelect.expand') handle.expandSmartSelect() else if (cmd === 'editor.action.smartSelect.shrink') handle.shrinkSmartSelect() + else if (cmd === 'editor.action.moveLinesUpAction') handle.moveLineUp() + else if (cmd === 'editor.action.moveLinesDownAction') handle.moveLineDown() + else if (cmd === 'editor.action.copyLinesUpAction') handle.copyLineUp() + else if (cmd === 'editor.action.copyLinesDownAction') handle.copyLineDown() + else if (cmd === 'editor.action.formatDocument') void formatDocumentAction() + else if (cmd === 'editor.action.revealDefinition') void handleGoToDefinition() + else if (cmd === 'editor.action.goToReferences') void handleGoToReferences() }, } - }, []) + }, [formatDocumentAction, handleGoToDefinition, handleGoToReferences]) // MenuBar zoom helpers (mirror what the scroll-wheel handler does) const zoomIn = useCallback(() => setFontSize(f => Math.min(f + 1, 36)), []) diff --git a/app/frontend/src/fullscreen/fullscreen.scss b/app/frontend/src/fullscreen/fullscreen.scss index 59b5cdf..d5d8d2d 100644 --- a/app/frontend/src/fullscreen/fullscreen.scss +++ b/app/frontend/src/fullscreen/fullscreen.scss @@ -1,4 +1,4 @@ -/* ── Root layout ──────────────────────────────────────────────────────────────── */ +/* ── Root layout ──────────────────────────────────────────────────────────────── */ .ide-root { display: flex; flex-direction: row; @@ -529,6 +529,7 @@ min-width: 0; overflow: hidden; background: var(--ide-editor-bg); + position: relative; } /* Row that holds the panes side-by-side */ @@ -774,6 +775,106 @@ margin: 0 4px; } +/* ── Go-to results panel ─────────────────────────────────────────────────────── */ +.ide-goto-results { + flex-shrink: 0; + border-top: 1px solid var(--ide-border-lo); + background: var(--ide-bg-alt); + max-height: 220px; + display: flex; + flex-direction: column; + font-size: 12px; + font-family: var(--ide-font-mono); +} +.ide-goto-results__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + background: var(--ide-bg-hi); + border-bottom: 1px solid var(--ide-border-lo); + flex-shrink: 0; +} +.ide-goto-results__title { + color: var(--ide-fg); + font-size: 11.5px; + font-weight: 500; + font-family: inherit; +} +.ide-goto-results__count { + color: var(--ide-fg-dim); + font-weight: 400; +} +.ide-goto-results__close { + background: none; + border: none; + padding: 2px; + cursor: pointer; + color: var(--ide-fg-dim); + line-height: 0; + &:hover { color: var(--ide-fg); } +} +.ide-goto-results__empty { + padding: 8px 12px; + color: var(--ide-fg-dim); + font-size: 12px; +} +.ide-goto-results__list { + overflow-y: auto; + flex: 1; +} +.ide-goto-results__row { + display: flex; + align-items: baseline; + gap: 12px; + width: 100%; + padding: 3px 12px; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: var(--ide-fg); + &:hover { background: var(--ide-bg-hi); } +} +.ide-goto-results__loc { + flex-shrink: 0; + color: var(--ide-fg-dim); + font-size: 11.5px; + white-space: nowrap; +} +.ide-goto-results__line { + color: var(--ide-accent); +} +.ide-goto-results__text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + color: var(--ide-fg); + opacity: 0.8; +} + +/* ── Format document toast ───────────────────────────────────────────────────── */ +.ide-format-toast { + position: absolute; + bottom: 28px; + right: 12px; + background: var(--ide-bg-alt, #2a2a2e); + color: var(--ide-text-hi, #cccccc); + border: 1px solid var(--ide-border, #3a3a3e); + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + font-family: var(--font-mono, monospace); + pointer-events: none; + z-index: 9000; + white-space: nowrap; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; +} + /* ── New File / Folder dialog ────────────────────────────────────────────────── */ .ni-overlay { position: fixed; diff --git a/cpp/src/search.cpp b/cpp/src/search.cpp index 8ff7b56..552deac 100644 --- a/cpp/src/search.cpp +++ b/cpp/src/search.cpp @@ -287,6 +287,120 @@ json content_impl(const std::string& root_path, const std::string& query, return results; } +// Run ripgrep and return raw NDJSON output. Returns empty string + sets +// warning if rg is not found. extra_flags are appended before the pattern. +std::string run_rg(const std::string& root_path, const std::string& extra_flags, + const std::string& pattern, std::string& warning) { + std::string output; +#ifdef _WIN32 + wchar_t rg_path[MAX_PATH] = {}; + if (!SearchPathW(nullptr, L"rg.exe", nullptr, MAX_PATH, rg_path, nullptr)) { + warning = "ripgrep not found"; + return output; + } + std::wstring cmd = L"\""; + cmd += rg_path; + cmd += L"\" --json "; + // Append extra_flags as wide string + if (!extra_flags.empty()) { + int nf = MultiByteToWideChar(CP_UTF8, 0, extra_flags.data(), (int)extra_flags.size(), nullptr, 0); + std::wstring wf(nf, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, extra_flags.data(), (int)extra_flags.size(), wf.data(), nf); + cmd += wf + L" "; + } + auto wq = from_u8(pattern).wstring(); + cmd += L"\"" + wq + L"\" "; + cmd += L"\"" + from_u8(root_path).wstring() + L"\""; + + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE rd = INVALID_HANDLE_VALUE, wr = INVALID_HANDLE_VALUE; + CreatePipe(&rd, &wr, &sa, 0); + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + STARTUPINFOW si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdOutput = wr; + si.hStdError = INVALID_HANDLE_VALUE; + si.hStdInput = INVALID_HANDLE_VALUE; + PROCESS_INFORMATION pi{}; + std::wstring cmdBuf = cmd; + if (!CreateProcessW(nullptr, cmdBuf.data(), nullptr, nullptr, TRUE, + CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) { + CloseHandle(rd); CloseHandle(wr); + warning = "ripgrep not found"; + return output; + } + CloseHandle(wr); + CloseHandle(pi.hThread); + char buf[4096]; DWORD n; + while (ReadFile(rd, buf, sizeof(buf), &n, nullptr) && n > 0) output.append(buf, n); + CloseHandle(rd); + WaitForSingleObject(pi.hProcess, 10000); + CloseHandle(pi.hProcess); +#else + auto shell_quote = [](const std::string& s) -> std::string { + std::string r = "'"; + for (char c : s) { if (c == '\'') r += "'\\''"; else r += c; } + r += "'"; + return r; + }; + std::string cmd = "rg --json " + extra_flags + " " + + shell_quote(pattern) + " " + shell_quote(root_path) + " 2>/dev/null"; + FILE* f = popen(cmd.c_str(), "r"); + if (!f) { warning = "ripgrep not found"; return output; } + char buf[4096]; + while (fgets(buf, sizeof(buf), f)) output += buf; + pclose(f); +#endif + return output; +} + +// Parse run_rg output into [{path, line, text}] relative to root_path. +json parse_rg_results(const std::string& output, const std::string& root_path, int max_results) { + json results = json::array(); + std::istringstream ss(output); + std::string line; + while (std::getline(ss, line) && (int)results.size() < max_results) { + if (line.empty()) continue; + try { + auto obj = json::parse(line); + if (obj.value("type", std::string{}) != "match") continue; + auto& data = obj["data"]; + std::string path = data["path"].value("text", std::string{}); + std::error_code ec; + auto rel = fs::relative(from_u8(path), from_u8(root_path), ec).generic_u8string(); + int line_no = data.value("line_number", 0); + std::string text = data["lines"].value("text", std::string{}); + if (!text.empty() && (text.back() == '\n' || text.back() == '\r')) text.pop_back(); + if (!text.empty() && text.back() == '\r') text.pop_back(); + results.push_back({{"path", rel}, {"line", line_no}, {"text", text}}); + } catch (...) {} + } + return results; +} + +// Go to Definition: search for declaration patterns for the given symbol. +json definition_impl(const std::string& root_path, const std::string& symbol, + std::string& warning) { + // Pattern matches common declaration keywords before the symbol name + std::string pattern = + "\\b(?:function|class|const|let|var|type|interface|enum|struct|fn|def|sub|func|proc|macro)\\s+" + + symbol + "\\b"; + std::string flags = "--max-count=5 --pcre2"; + auto output = run_rg(root_path, flags, pattern, warning); + if (!warning.empty()) return json::array(); + return parse_rg_results(output, root_path, 50); +} + +// Go to References: search for all word-boundary occurrences of the symbol. +json references_impl(const std::string& root_path, const std::string& symbol, + std::string& warning) { + std::string flags = "--word-regexp --max-count=200"; + auto output = run_rg(root_path, flags, symbol, warning); + if (!warning.empty()) return json::array(); + return parse_rg_results(output, root_path, 200); +} + // Path completion — matches Go's search.Completions. json complete_path_impl(const std::string& cwd, const std::string& dir, const std::string& prefix) { @@ -362,6 +476,26 @@ bool dispatch(const std::string& type, const json& msg, reply(body); return true; } + if (type == "search.definition") { + auto path = msg.value("path", std::string{}); + auto symbol = msg.value("symbol", std::string{}); + std::string warning; + auto results = definition_impl(path, symbol, warning); + json body = {{"results", results}}; + if (!warning.empty()) body["warning"] = warning; + reply(body); + return true; + } + if (type == "search.references") { + auto path = msg.value("path", std::string{}); + auto symbol = msg.value("symbol", std::string{}); + std::string warning; + auto results = references_impl(path, symbol, warning); + json body = {{"results", results}}; + if (!warning.empty()) body["warning"] = warning; + reply(body); + return true; + } if (type == "complete.path") { auto cwd = msg.value("cwd", std::string{}); auto dir = msg.value("dir", std::string{});