From 6114ce440dda0f03b96d91c3563d8b4750b96237 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Fri, 19 Jun 2026 10:41:32 +0200 Subject: [PATCH] update lots of stuff --- src/renderer/src/App.tsx | 259 +++++++++++++++++++++++--------- src/renderer/src/components.tsx | 27 ++-- src/renderer/src/editor.tsx | 10 +- src/renderer/src/overlays.tsx | 68 +++++++-- src/renderer/src/styles.css | 13 +- src/renderer/src/terminals.tsx | 20 ++- test/app-interactions.test.tsx | 6 +- 7 files changed, 299 insertions(+), 104 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 46ee572..81e46ad 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,7 +37,7 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD return
} -function RightColumn({ width, onFocus }: { width: number; onFocus: () => void }): React.ReactElement { +function RightColumn({ width, active, onFocus }: { width: number; active: boolean; onFocus: () => void }): React.ReactElement { const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52)) const ref = useRef(null) useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac]) @@ -46,7 +46,7 @@ function RightColumn({ width, onFocus }: { width: number; onFocus: () => void }) setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h))) } return ( -
+
@@ -88,7 +88,7 @@ export function App(): React.ReactElement { const [toasts, setToasts] = useState([]) const [splitFor, setSplitFor] = useState(null) const [commitMsg, setCommitMsg] = useState('') - const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null) + const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null) const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null) const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null) const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null) @@ -161,15 +161,20 @@ export function App(): React.ReactElement { } // Proportional columns, two regimes (Editor C is the flex remainder): - // ≥ 1650px (roomy) → Git 10% · Explorer 10% · Editor 40% · Right 40% + // ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38% // (no focus-driven changes — everything fits) - // < 1650px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus: + // < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus: // default Editor 40% / Right 30% // focus editor → Editor 50% / Right 20% // focus agent/terminal → Editor 20% / Right 50% // Re-applied on resize + focus change; dragging still works in between. - const FOCUS_RESIZE_BELOW = 1650 + const FOCUS_RESIZE_BELOW = 1600 const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default') + // Which column currently has focus — drives the active-panel tint and keyboard + // navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu). + const [activePanel, setActivePanel] = useState<'git' | 'tree' | 'editor' | 'terminal' | null>(null) + const [gitSel, setGitSel] = useState(0) + const [treeSel, setTreeSel] = useState(0) // Auto panel management: re-fit columns on resize/focus. Manually dragging a // splitter switches it off (the user took control); the title-bar toggle // turns it back on (and immediately re-fits). @@ -184,8 +189,8 @@ export function App(): React.ReactElement { const w = window.innerWidth if (w >= FOCUS_RESIZE_BELOW) { setGitW(Math.round(w * 0.1)) - setTreeW(Math.round(w * 0.1)) - setRightW(Math.round(w * 0.4)) + setTreeW(Math.round(w * 0.15)) + setRightW(Math.round(w * 0.38)) } else { setGitW(Math.round(w * 0.15)) setTreeW(Math.round(w * 0.15)) @@ -198,6 +203,43 @@ export function App(): React.ReactElement { return () => window.removeEventListener('resize', apply) }, [focusZone, autoResize]) + // Flat, render-order lists of the rows in Git (A) and Explorer (B) — the targets + // for arrow-key navigation. Git: staged group then changes group. Tree: the + // currently-visible nodes (honouring expansion + the hidden-files toggle). + const gitNav = useMemo(() => { + const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path)) + const stagedRows = visible.filter((c) => proj.staged.has(c.path)) + const changeRows = visible.filter((c) => !proj.staged.has(c.path)) + return [...stagedRows, ...changeRows].map((c) => c.path) + }, [proj.changes, proj.staged]) + const treeNav = useMemo(() => { + const out: { path: string; type: 'dir' | 'file' }[] = [] + const walk = (node: FileNode): void => { + if (node.type === 'dir') { + const isOpen = openDirs.has(node.path) || node.path === '' + if (node.path !== '') out.push({ path: node.path, type: 'dir' }) + if (isOpen) (node.children || []).filter((c) => showHidden || !c.name.startsWith('.')).forEach(walk) + } else { + out.push({ path: node.path, type: 'file' }) + } + } + if (proj.tree) walk(proj.tree) + return out + }, [proj.tree, openDirs, showHidden]) + const gitSelPath = gitNav[gitSel] ?? null + const treeSelItem = treeNav[treeSel] ?? null + + // Keep the row cursors in range as the lists shrink/grow. + useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length]) + useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length]) + // Scroll the selected row into view when navigating with the keyboard. + useEffect(() => { + if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' }) + }, [gitSel, activePanel]) + useEffect(() => { + if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' }) + }, [treeSel, activePanel]) + // Flash the branch · repository banner for ~2s each time the window gains focus. useEffect(() => { let timer: ReturnType @@ -412,58 +454,90 @@ export function App(): React.ReactElement { } // ---- context menus ---- + // Naming contract: every "Pass on …" action opens the input popup (so the user + // can attach a note), and its "Copy …" twin sits directly below it. Pass first, + // Copy under it. + function buildMenu(target: ContextTarget, x: number, y: number): { items: Menu['items']; note: string; path?: string } { + const spark = Icon.spark({ style: { color: 'var(--ren)' } }) + const openPass = (ref: string, code?: string): void => setPassPopup({ x, y, ref, code }) + if (target.kind === 'editor') { + const hasCode = !!(target.code && target.code.length) + const ref = target.sel + ? (target.sel.start === target.sel.end ? `${target.path}:${target.sel.start}` : `${target.path}:${target.sel.start}-${target.sel.end}`) + : `${target.path}:${target.line ?? 1}` + return { + note: ref, + items: [ + { primary: true, icon: spark, label: hasCode ? 'Pass on selection' : 'Pass on reference', onClick: () => openPass(ref, hasCode ? target.code : undefined) }, + { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, + ], + } + } + const isDir = target.kind === 'dir' + const ref = isDir ? target.path + '/' : target.path + const name = target.path.split('/').pop() as string + const items: Menu['items'] = [ + { primary: true, icon: spark, label: 'Pass on reference', onClick: () => openPass(ref) }, + { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, + ] + // A folder's reference already is its path; only files get the basename twin. + if (!isDir) { + items.push({ icon: spark, label: 'Pass on file name', onClick: () => openPass(name) }) + items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') }) + } + if (isDir) { + items.push({ sep: true }) + items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x, y, dir: target.path }) }) + items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x, y, dir: target.path }) }) + } + if (target.kind === 'git') { + items.push({ sep: true }) + const isStaged = proj.staged.has(target.path) + items.push(isStaged + ? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) } + : { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) }) + items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) }) + items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) }) + } + // Show in Finder + delete — for explorer files and folders (not git rows). + if (isDir || target.kind === 'file') { + items.push({ sep: true }) + items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) }) + items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) }) + } + return { items, note: ref, path: target.path } + } + function openMenuAt(x: number, y: number, target: ContextTarget): void { + const { items, note, path } = buildMenu(target, x, y) + setMenu({ x, y, note, path, items }) + } function openMenu(e: React.MouseEvent, target: ContextTarget): void { e.preventDefault(); e.stopPropagation() - const sparkSend = (ref: string): Menu['items'][number] => ({ icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Send reference to agent', onClick: () => { window.dispatchEvent(new CustomEvent('agentPaste', { detail: ref })); toast('Passed to agent', ref) } }) - if (target.kind === 'editor') { - const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}` - const mx = e.clientX, my = e.clientY - setMenu({ - x: mx, y: my, note: ref, - items: [ - { primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, - { icon: Icon.spark({ style: { color: 'var(--ren)' } }), label: 'Pass on to Agent', onClick: () => setPassPopup({ x: mx, y: my, ref }) }, - ], - }) - } else { - const isDir = target.kind === 'dir' - const ref = isDir ? target.path + '/' : target.path - const name = target.path.split('/').pop() as string - const items: Menu['items'] = [ - { primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, - sparkSend(ref), - ] - // For a folder, "Copy reference" already yields the path — so only files get - // the extra "Copy file name" (just the basename, distinct from the path). - if (!isDir) { - items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') }) - } - if (isDir) { - const mx = e.clientX, my = e.clientY - items.push({ sep: true }) - items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x: mx, y: my, dir: target.path }) }) - items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x: mx, y: my, dir: target.path }) }) - } - if (!isDir) { - items.push({ sep: true }) - if (target.kind === 'git') { - const isStaged = proj.staged.has(target.path) - items.push(isStaged - ? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) } - : { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) }) - items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) }) - items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) }) - } - items.push({ icon: Icon.file({ style: { color: 'var(--ren)' } }), label: 'Open file', onClick: () => openFile(target.path) }) - } - // Show in Finder + delete — for explorer files and folders (not git rows). - if (isDir || target.kind === 'file') { - items.push({ sep: true }) - items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) }) - items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) }) - } - setMenu({ x: e.clientX, y: e.clientY, note: ref, path: target.path, items }) + openMenuAt(e.clientX, e.clientY, target) + } + // ⌘→ inside Git/Explorer: open the selected row's menu, anchored to its row. + function openPanelMenu(): boolean { + if (activePanel === 'git' && gitSelPath) { + const r = document.querySelector('.git-row.kbd')?.getBoundingClientRect() + openMenuAt(r ? r.right - 40 : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: proj.staged.has(gitSelPath) }) + return true } + if (activePanel === 'tree' && treeSelItem) { + const r = document.querySelector('.tree-row.kbd')?.getBoundingClientRect() + openMenuAt(r ? r.right - 40 : 220, r ? r.top + 4 : 120, { path: treeSelItem.path, kind: treeSelItem.type }) + return true + } + return false + } + // ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder. + function openPanelSelection(): boolean { + if (activePanel === 'git' && gitSelPath) { openFile(gitSelPath, { diff: true }); return true } + if (activePanel === 'tree' && treeSelItem) { + if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path) + else openFile(treeSelItem.path) + return true + } + return false } // ⌘M cycles the active file through whatever views it supports: a changed file @@ -496,7 +570,20 @@ export function App(): React.ReactElement { : (window.getSelection()?.toString() ?? '') return raw.split('\n')[0].trim() } - // ⌘→ with a selection: open Pass-on-to-Agent for the selected line range. + // Reconstruct the source for a line range from the rendered Diff/Original view + // (its rows carry data-line + a .ln-code span). + function codeFromDom(start: number, end: number): string { + const parts: string[] = [] + document.querySelectorAll('.editor .ln-row').forEach((row) => { + const ln = row.dataset.line + if (!ln) return + const n = +ln + if (n >= start && n <= end) parts.push(row.querySelector('.ln-code')?.textContent ?? '') + }) + return parts.join('\n') + } + // ⌘→ with a selection: open Pass-on-to-Agent for the selected line range, + // carrying the selected code so it's passed as a fenced block. // Returns true when a selection was found (so we can swallow the key). function passSelection(): boolean { if (!active) return false @@ -507,20 +594,22 @@ export function App(): React.ReactElement { const s = v.slice(0, ae.selectionStart).split('\n').length const en = v.slice(0, ae.selectionEnd).split('\n').length const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}` + const code = v.slice(ae.selectionStart, ae.selectionEnd) const r = ae.getBoundingClientRect() - setPassPopup({ x: r.left + 60, y: r.top + 70, ref }) + setPassPopup({ x: r.left + 60, y: r.top + 70, ref, code }) return true } // Diff / Original (PaneView) — line range tracked in `selection` state. if (selection && selection.path === active && selection.start !== selection.end) { const ref = `${active}:${selection.start}-${selection.end}` + const code = codeFromDom(selection.start, selection.end) const dom = window.getSelection() let x = window.innerWidth / 2, y = 150 if (dom && dom.rangeCount && !dom.isCollapsed) { const rr = dom.getRangeAt(0).getBoundingClientRect() if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 } } - setPassPopup({ x, y, ref }) + setPassPopup({ x, y, ref, code }) return true } return false @@ -541,6 +630,22 @@ export function App(): React.ReactElement { const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') // The history navigator owns the keyboard while open (it listens in capture phase). if (overlay === 'history') return + // An open context menu owns the keyboard (arrows / ↵ / esc handled there). + if (menu) return + const inPanel = activePanel === 'git' || activePanel === 'tree' + // Arrow up/down move the row cursor in the focused Git/Explorer panel. + if (!meta && !inField && inPanel && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.preventDefault() + const len = activePanel === 'git' ? gitNav.length : treeNav.length + if (len === 0) return + const set = activePanel === 'git' ? setGitSel : setTreeSel + set((s) => e.key === 'ArrowDown' ? Math.min(s + 1, len - 1) : Math.max(s - 1, 0)) + return + } + // ↵ opens the selected row (git → diff, file → open, folder → toggle). + if (!meta && !inField && inPanel && e.key === 'Enter') { + if (openPanelSelection()) { e.preventDefault(); return } + } if (e.key === 'Escape') { if (splitFor) setSplitFor(null) else if (overlay) setOverlay(null) @@ -564,8 +669,12 @@ export function App(): React.ReactElement { // ⌘D deletes the current file (with confirmation). else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) } else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() } - // ⌘→ with a text selection passes that selection to the agent (else native nav). - else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() } + // ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes + // the current text selection to the agent (else native nav). + else if (meta && e.key === 'ArrowRight') { + if (inPanel && !inField) { if (openPanelMenu()) e.preventDefault() } + else if (passSelection()) e.preventDefault() + } // ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself). else if (meta && e.key === 'Enter') { if (ae && ae.classList.contains('commit-input')) return @@ -589,7 +698,7 @@ export function App(): React.ReactElement { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm]) + }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel]) const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code') @@ -647,25 +756,29 @@ export function App(): React.ReactElement { {/* workbench */}
-
+
setActivePanel('git')}> 300} /> + onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} + kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
{ setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> -
+
setActivePanel('tree')}> {proj.tree ? ( + onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} + kbdPath={activePanel === 'tree' ? (treeSelItem?.path ?? null) : null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} /> ) : (
)}
{ setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} /> -
setFocusZone('editor')}> +
{ setFocusZone('editor'); setActivePanel('editor') }}> { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }} onContext={openMenu} @@ -680,15 +793,15 @@ export function App(): React.ReactElement { }) }} /> {/* keyed by root so the PTYs respawn in the new cwd when the project switches */} - {proj.ready && setFocusZone('terminal')} />} + {proj.ready && { setFocusZone('terminal'); setActivePanel('terminal') }} />}
{/* overlays */} {splitFor && setSplitFor(null)} onContext={openMenu} />} - {passPopup && { - const line = (text && text.trim() ? text.trim() + ' ' : '') + passPopup.ref - window.dispatchEvent(new CustomEvent('agentPaste', { detail: line })) + {passPopup && { + window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload })) setPassPopup(null) toast('Passed to agent', passPopup.ref) }} diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index 5307356..fbe5e8c 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -61,15 +61,17 @@ export interface ContextTarget { staged?: boolean sel?: { start: number; end: number } line?: number + code?: string } export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void /* ============ Git / Source Control panel ============ */ -function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, onToggleStage }: { +function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onContext, onToggleStage }: { c: Change staged: boolean activePath: string | null ctxPath: string | null + kbdPath: string | null showDir: boolean onOpen: OpenFile onContext: OnContext @@ -79,7 +81,7 @@ function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, on const dir = c.path.split('/').slice(0, -1).join('/') const dirShown = showDir && !!dir return ( -
onOpen(c.path, { diff: true })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} title={c.path}> @@ -95,7 +97,7 @@ function GitRow({ c, staged, activePath, ctxPath, showDir, onOpen, onContext, on ) } -export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, showDir }: { +export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: { branch: string changes: Change[] staged: Set @@ -111,6 +113,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom onContext: OnContext activePath: string | null ctxPath: string | null + kbdPath: string | null showDir: boolean }): React.ReactElement { const visible = changes.filter((c) => !committed.has(c.path)) @@ -138,7 +141,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {stagedList.length > 0 && }
{stagedList.length > 0 ? stagedList.map((c) => ( - )) : (
Nothing staged — use + to stage a file
@@ -151,7 +154,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom {changesList.length > 0 && }
{changesList.length > 0 ? changesList.map((c) => ( - )) : (
All changes staged
@@ -172,7 +175,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom } /* ============ File Tree ============ */ -function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: { +function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: { node: FileNode depth: number openDirs: Set @@ -181,6 +184,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP onContext: OnContext activePath: string | null ctxPath: string | null + kbdPath: string | null changeMap: Record committed: Set showHidden: boolean @@ -191,7 +195,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP return ( {node.path !== '' && ( -
toggleDir(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> @@ -204,14 +208,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP .map((c) => ( + onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} /> ))} ) } const status = committed && committed.has(node.path) ? null : changeMap[node.path] return ( -
onOpen(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} @@ -224,7 +228,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP ) } -export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, changeMap, committed, showHidden }: { +export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: { tree: FileNode openDirs: Set toggleDir: (path: string) => void @@ -232,6 +236,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP onContext: OnContext activePath: string | null ctxPath: string | null + kbdPath: string | null changeMap: Record committed: Set showHidden: boolean @@ -240,7 +245,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
+ onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
) diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx index 0070929..922c05c 100644 --- a/src/renderer/src/editor.tsx +++ b/src/renderer/src/editor.tsx @@ -54,7 +54,8 @@ function CodeEditor({ path, text, lang, onChange, onContext }: { const info: Parameters[1] = { path, kind: 'editor', line: startLine } if (ta.selectionEnd > ta.selectionStart) { const endLine = text.slice(0, ta.selectionEnd).split('\n').length - if (endLine !== startLine) info.sel = { start: startLine, end: endLine } + info.sel = { start: startLine, end: endLine } + info.code = ta.value.slice(ta.selectionStart, ta.selectionEnd) } onContext(e, info) } @@ -144,6 +145,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se if (el) { setCursor({ path, line: +el.dataset.line!, col: caretCol(sel) }); setSelection(null) } } } + // Join the source lines covered by a selection — the code-block payload for "Pass on selection". + function codeForRange(s: number, en: number): string { + return lines.filter((l) => l.no != null && l.no >= s && l.no <= en).map((l) => l.text).join('\n') + } function handleContext(e: React.MouseEvent): void { e.preventDefault() const sel = window.getSelection() @@ -152,9 +157,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se const f = sel && sel.focusNode && climbToLine(sel.focusNode) if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) { const s = Math.min(+a.dataset.line!, +f.dataset.line!), en = Math.max(+a.dataset.line!, +f.dataset.line!) - info.sel = { start: s, end: en }; info.line = s + info.sel = { start: s, end: en }; info.line = s; info.code = codeForRange(s, en) } else if (selection && selection.path === path && selection.start !== selection.end) { info.sel = { start: selection.start, end: selection.end }; info.line = selection.start + info.code = codeForRange(selection.start, selection.end) } else { let no: number | null = null const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index c5e8980..39a78a8 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -15,6 +15,18 @@ export interface MenuItem { } export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] } export interface Toast { id: number; title: string; ref?: string } + +/** Build the exact text inserted into the agent for a "Pass on …" action. + * - Plain reference / name → `note => thing` (or just `thing` with no note). + * - Selected code → `note [ref](ref)` followed by the code in a fenced block. */ +export function buildPass(note: string, ref: string, code?: string): string { + const n = note.trim() + if (code != null) { + const head = (n ? n + ' ' : '') + `[${ref}](${ref})` + return head + '\n```\n' + code + '\n```' + } + return n ? `${n} => ${ref}` : ref +} interface ContentHit { no: number; ln: string; ix: number } interface ContentGroup { path: string; hits: ContentHit[] } @@ -343,7 +355,9 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [ { keys: ['↵'], label: 'Open the selected list item' }, { keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' }, { keys: ['⌘', '→'], label: 'Search: focus the column to the right' }, - { keys: ['⌘', '→'], label: 'Pass the selected text to the agent' }, + { keys: ['↑', '↓'], label: 'Git/Explorer: move the row cursor (panel must be focused)' }, + { keys: ['↵'], label: 'Git/Explorer: open the selected row' }, + { keys: ['⌘', '→'], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' }, { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', '↵'], label: 'Commit the staged files' }, @@ -414,13 +428,43 @@ export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onC export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null { const ref = useRef(null) + // Index of the first selectable (non-separator) item, for keyboard highlight. + const items = menu?.items ?? [] + const firstSel = items.findIndex((it) => !it.sep) + const [hi, setHi] = useState(firstSel) + const hiRef = useRef(hi); hiRef.current = hi + // Reset the highlight to the first selectable item whenever the menu reopens + // (right-click can swap the target without unmounting this component). + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { setHi(items.findIndex((it) => !it.sep)) }, [menu]) + // Step the highlight to the next/previous selectable item, skipping separators. + function step(dir: 1 | -1): void { + setHi((cur) => { + let i = cur + for (let n = 0; n < items.length; n++) { + i = (i + dir + items.length) % items.length + if (!items[i].sep) return i + } + return cur + }) + } useEffect(() => { const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() } - const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() } + const k = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() } + else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); step(1) } + else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); step(-1) } + else if (e.key === 'Enter') { + e.preventDefault(); e.stopPropagation() + const it = items[hiRef.current] + if (it && !it.sep) { it.onClick?.(); onClose() } + } + } document.addEventListener('mousedown', h) - document.addEventListener('keydown', k) - return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k) } - }, []) + document.addEventListener('keydown', k, true) + return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length]) if (!menu) return null const x = Math.min(menu.x, window.innerWidth - 270) const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60)) @@ -428,7 +472,8 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
{menu.note &&
{menu.note}
} {menu.items.map((it, i) => it.sep ?
: ( -
setHi(i)} onClick={() => { it.onClick?.(); onClose() }}> {it.icon} {it.label} @@ -453,11 +498,12 @@ export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement { ) } -export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { +export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: { x: number y: number refStr: string - onConfirm: (text: string) => void + code?: string + onConfirm: (payload: string) => void onCancel: () => void }): React.ReactElement { const [text, setText] = useState('') @@ -473,7 +519,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { }, []) const left = Math.min(x, window.innerWidth - 360) const top = Math.min(y + 6, window.innerHeight - 150) - const preview = (text.trim() ? text.trim() + ' ' : '') + refStr + const payload = buildPass(text, refStr, code) return (
{Icon.spark()}Pass on to Agentesc
@@ -481,10 +527,10 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { placeholder="Add a note (optional)…" onChange={(e) => setText(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); onConfirm(text) } + if (e.key === 'Enter') { e.preventDefault(); onConfirm(payload) } else if (e.key === 'Escape') { e.preventDefault(); onCancel() } }} /> -
inserts{preview}
+
inserts{payload}
insert into agent · esc cancel
) diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index ecec687..3bbba66 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -120,8 +120,13 @@ body { .workbench { flex:1; display:flex; min-height:0; } .col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); } -.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; } +/* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */ +.col.editor-col { flex:1; min-width:240px; } .col.right-col { background:var(--bg-1); } +/* Active panel: subtle lighter-gray tint on the focused column. C uses the same + tint as the side panels, so the file view stays in step with B in/out of focus. */ +.col.panel-active { background:#22252a; } +.col.right-col.panel-active { background:#1e2024; } .splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; } .splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; } @@ -165,6 +170,8 @@ body { .git-row.ctx .git-act { visibility:visible; } .git-row.active { background:var(--sel); } .git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); } +.git-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); } +.git-row.kbd .git-act { visibility:visible; } .git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; } .git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);} .git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } @@ -185,6 +192,7 @@ body { .tree-row:hover, .tree-row.ctx { background:var(--hover); } .tree-row.active { background:var(--sel); } .tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); } +.tree-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); } .tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; } .tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; } .tree-row.active .tree-label { color:var(--fg-0); } @@ -399,6 +407,7 @@ body { .pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; } .pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; } .pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; } .pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); } .pass-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; } @@ -410,7 +419,7 @@ body { /* context menu */ .ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); } .ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); } -.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); } +.ctx-item:hover, .ctx-item.hi { background:var(--accent-soft); color:var(--fg-0); } .ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); } .ctx-item.primary { color:var(--fg-0); } .ctx-item.primary .ic { color:var(--accent); } diff --git a/src/renderer/src/terminals.tsx b/src/renderer/src/terminals.tsx index d55abef..585e1a9 100644 --- a/src/renderer/src/terminals.tsx +++ b/src/renderer/src/terminals.tsx @@ -35,6 +35,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem const termRef = useRef(null) const [, setLive] = useState(kind === 'agent') const [menu, setMenu] = useState(null) + // Best-effort "is the agent composer non-empty?" flag. A passed reference must + // land on its own line, so we prepend a newline — UNLESS the composer is empty + // (no leading blank line). We can't read the CLI's input buffer, so we infer: + // typing a printable char marks it dirty, pressing Enter (submit) clears it. + const composerDirty = useRef(false) useEffect(() => { const bridge = window.helder @@ -75,7 +80,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem function onPaste(e: Event): void { if (kind !== 'agent' || !bridge || id < 0) return const text = (e as CustomEvent).detail - bridge.pty.write(id, '\x1b[200~' + text + '\n\x1b[201~') + // Own line for the reference; skip the leading newline on an empty composer. + const lead = composerDirty.current ? '\n' : '' + bridge.pty.write(id, '\x1b[200~' + lead + text + '\x1b[201~') + composerDirty.current = true term.focus() } @@ -90,7 +98,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem } offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) }) offExit = bridge.pty.onExit((tid) => { if (tid === id) { term.write('\r\n\x1b[90m[process exited]\x1b[0m\r\n'); setLive(false) } }) - term.onData((d) => bridge.pty.write(id, d)) + term.onData((d) => { + // Track composer emptiness for the agent pane: Enter submits (→ empty), + // a printable keystroke means there's content on the current line. + if (kind === 'agent') { + if (d.includes('\r')) composerDirty.current = false + else if (d >= ' ') composerDirty.current = true + } + bridge.pty.write(id, d) + }) term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows)) if (kind === 'agent') window.addEventListener('agentPaste', onPaste) }) diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index cf36ef1..cd3e5af 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -37,7 +37,7 @@ async function expandTo(c: HTMLElement, ...folders: string[]): Promise { } describe('Pass on to Agent', () => { - it('inserts " " via the agentPaste event', async () => { + it('inserts " => " via the agentPaste event', async () => { const received: string[] = [] const handler = (e: Event): void => { received.push((e as CustomEvent).detail) } window.addEventListener('agentPaste', handler) @@ -57,7 +57,7 @@ describe('Pass on to Agent', () => { }) fireEvent.contextMenu(ta) const pass = await waitFor(() => { - const item = find(c, '.ctx-item', 'Pass on to Agent') + const item = find(c, '.ctx-item', 'Pass on reference') if (!item) throw new Error('menu not open') return item }) @@ -70,7 +70,7 @@ describe('Pass on to Agent', () => { fireEvent.change(input, { target: { value: 'look here' } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => expect(received.length).toBeGreaterThan(0)) - expect(received[0]).toBe('look here public/assets/store.js:1') + expect(received[0]).toBe('look here => public/assets/store.js:1') } finally { window.removeEventListener('agentPaste', handler) }