From bc265bdd731f0575ecd44ab78f872c10bc88912b Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:25:06 -0400 Subject: [PATCH 1/3] feat(editor): implement move/copy line shortcuts (Alt+Up/Down, Shift+Alt+Up/Down) --- app/frontend/src/components/GpuEditor.tsx | 134 +++++++++++++++++- app/frontend/src/fullscreen/FullscreenIDE.tsx | 4 + 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/app/frontend/src/components/GpuEditor.tsx b/app/frontend/src/components/GpuEditor.tsx index 56ab814..ea22c1d 100644 --- a/app/frontend/src/components/GpuEditor.tsx +++ b/app/frontend/src/components/GpuEditor.tsx @@ -60,6 +60,10 @@ export interface GpuEditorHandle { goToLine: (line: number) => void expandSmartSelect: () => void shrinkSmartSelect: () => void + moveLineUp: () => void + moveLineDown: () => void + copyLineUp: () => void + copyLineDown: () => void } interface Props { @@ -1750,6 +1754,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 +2090,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 } @@ -2041,7 +2167,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, openFind, redo, requestCompletions, save, selectAll, shrinkSmartSelect, undo]) const onInput = useCallback((e: React.FormEvent) => { const ta = e.currentTarget @@ -2273,7 +2399,11 @@ 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() }, + }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect, moveLineUp, moveLineDown, copyLineUp, copyLineDown]) return (
diff --git a/app/frontend/src/fullscreen/FullscreenIDE.tsx b/app/frontend/src/fullscreen/FullscreenIDE.tsx index b2e8fa8..6ac64c2 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -807,6 +807,10 @@ 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() }, } }, []) From 5c185f18605180eb90a2f51cce5fdf8968f9f31a Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:38:41 -0400 Subject: [PATCH 2/3] feat(editor): implement Format Document (Shift+Alt+F) via external formatters Hooks up the previously no-op Format Document command to real formatters: - TS/JS/TSX/JSX/CSS/HTML/MD: prettier --write - Rust: rustfmt - Go: gofmt -w - Python: black - C/C++/H: clang-format -i - JSON: in-memory JSON.stringify(JSON.parse(...), null, 2) If no formatter is mapped for the language, shows a brief toast. If the formatter binary is missing or errors, the first line of its output is shown. Cursor position is preserved (clamped to the new line count) after formatting. Closes #68 --- app/frontend/src/components/GpuEditor.tsx | 17 ++- app/frontend/src/fullscreen/FullscreenIDE.tsx | 74 ++++++++++++- app/frontend/src/fullscreen/fullscreen.scss | 103 +++++++++++++++++- 3 files changed, 189 insertions(+), 5 deletions(-) diff --git a/app/frontend/src/components/GpuEditor.tsx b/app/frontend/src/components/GpuEditor.tsx index ea22c1d..6a4a696 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' @@ -64,6 +64,7 @@ export interface GpuEditorHandle { moveLineDown: () => void copyLineUp: () => void copyLineDown: () => void + formatDocument: (newContent: string) => Promise } interface Props { @@ -2403,7 +2404,19 @@ const GpuEditor = forwardRef(function GpuEditor({ moveLineDown: () => { void moveLineDown() }, copyLineUp: () => { void copyLineUp() }, copyLineDown: () => { void copyLineDown() }, - }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect, moveLineUp, moveLineDown, copyLineUp, 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() + } + }, + }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect, moveLineUp, moveLineDown, copyLineUp, copyLineDown, applyRawEdits, ensureCursorVisible, draw]) return (
diff --git a/app/frontend/src/fullscreen/FullscreenIDE.tsx b/app/frontend/src/fullscreen/FullscreenIDE.tsx index 6ac64c2..d3807a0 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,9 @@ 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) + // ── status bar (per-panel — each GpuEditor reports its own state) ───────────── const [leftStatus, setLeftStatus] = useState(INITIAL_PANE_STATUS) const [rightStatus, setRightStatus] = useState(INITIAL_PANE_STATUS) @@ -467,6 +470,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) => { @@ -781,6 +846,10 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW )}
+ + {formatMsg && ( +
{formatMsg}
+ )}
) @@ -811,9 +880,10 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW 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() }, } - }, []) + }, [formatDocumentAction]) // 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; From 1c473a423b2c4f8996eabfdc84c5bb20b27ae4cc Mon Sep 17 00:00:00 2001 From: Kris Powers <85710701+KrisPowers@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:43:38 -0400 Subject: [PATCH 3/3] feat(editor): implement Go to Definition (F12) and Go to References (Shift+F12) Ripgrep-based first pass. F12 on a symbol searches the project for declaration patterns (function/class/const/let/var/etc.); single match navigates directly, multiple matches show a results panel. Shift+F12 lists all word-boundary occurrences. Empty results show a "No definition found" notice. - cpp/src/search.cpp: run_rg helper, definition_impl (pcre2 keyword pattern), references_impl (--word-regexp), search.definition + search.references IPC handlers - GpuEditor.tsx: getWordAtCursor() on handle, onGoToDefinition/onGoToReferences props, F12/Shift+F12 key handler - FullscreenIDE.tsx: gotoResults state, navigateToResult, handleGoToDefinition, handleGoToReferences, trigger wired up, results panel UI Closes #69 --- app/frontend/src/components/GpuEditor.tsx | 25 +++- app/frontend/src/fullscreen/FullscreenIDE.tsx | 98 ++++++++++++- cpp/src/search.cpp | 134 ++++++++++++++++++ 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/app/frontend/src/components/GpuEditor.tsx b/app/frontend/src/components/GpuEditor.tsx index 6a4a696..d4dbea2 100644 --- a/app/frontend/src/components/GpuEditor.tsx +++ b/app/frontend/src/components/GpuEditor.tsx @@ -65,6 +65,7 @@ export interface GpuEditorHandle { copyLineUp: () => void copyLineDown: () => void formatDocument: (newContent: string) => Promise + getWordAtCursor: () => Promise } interface Props { @@ -92,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 — @@ -472,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) @@ -2161,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() @@ -2168,7 +2177,7 @@ const GpuEditor = forwardRef(function GpuEditor({ } return } - }, [acceptCompletion, addCursorVertical, advanceSnippetStop, closeCompletions, closeFind, copyLineDown, copyLineUp, copySelection, deleteBackward, deleteForward, draw, expandSmartSelect, handleEnter, handleTypedChar, insertText, moveCursor, moveCursorsTo, moveLineDown, moveLineUp, 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 @@ -2416,7 +2425,19 @@ const GpuEditor = forwardRef(function GpuEditor({ draw() } }, - }), [save, undo, redo, selectAll, openFind, setCursorTo, expandSmartSelect, shrinkSmartSelect, moveLineUp, moveLineDown, copyLineUp, copyLineDown, applyRawEdits, 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 d3807a0..57afa90 100644 --- a/app/frontend/src/fullscreen/FullscreenIDE.tsx +++ b/app/frontend/src/fullscreen/FullscreenIDE.tsx @@ -133,6 +133,13 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW // ── 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) @@ -369,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). @@ -654,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} /> ) } @@ -824,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 */}
@@ -881,9 +975,11 @@ export default function FullscreenIDE({ cwd, theme, indentGuides, minimap, wordW 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]) + }, [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/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{});