This commit is contained in:
@@ -37,7 +37,7 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
|
|||||||
return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} />
|
return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} />
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
|
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)))
|
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="col right-col" style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
|
<div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
|
||||||
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
|
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
|
||||||
<Terminal kind="agent" />
|
<Terminal kind="agent" />
|
||||||
@@ -88,7 +88,7 @@ export function App(): React.ReactElement {
|
|||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
const [splitFor, setSplitFor] = useState<string | null>(null)
|
const [splitFor, setSplitFor] = useState<string | null>(null)
|
||||||
const [commitMsg, setCommitMsg] = useState('')
|
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 [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
|
||||||
const [newFolderPopup, setNewFolderPopup] = 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)
|
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):
|
// 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)
|
// (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%
|
// default Editor 40% / Right 30%
|
||||||
// focus editor → Editor 50% / Right 20%
|
// focus editor → Editor 50% / Right 20%
|
||||||
// focus agent/terminal → Editor 20% / Right 50%
|
// focus agent/terminal → Editor 20% / Right 50%
|
||||||
// Re-applied on resize + focus change; dragging still works in between.
|
// 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')
|
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
|
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
|
||||||
// splitter switches it off (the user took control); the title-bar toggle
|
// splitter switches it off (the user took control); the title-bar toggle
|
||||||
// turns it back on (and immediately re-fits).
|
// turns it back on (and immediately re-fits).
|
||||||
@@ -184,8 +189,8 @@ export function App(): React.ReactElement {
|
|||||||
const w = window.innerWidth
|
const w = window.innerWidth
|
||||||
if (w >= FOCUS_RESIZE_BELOW) {
|
if (w >= FOCUS_RESIZE_BELOW) {
|
||||||
setGitW(Math.round(w * 0.1))
|
setGitW(Math.round(w * 0.1))
|
||||||
setTreeW(Math.round(w * 0.1))
|
setTreeW(Math.round(w * 0.15))
|
||||||
setRightW(Math.round(w * 0.4))
|
setRightW(Math.round(w * 0.38))
|
||||||
} else {
|
} else {
|
||||||
setGitW(Math.round(w * 0.15))
|
setGitW(Math.round(w * 0.15))
|
||||||
setTreeW(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)
|
return () => window.removeEventListener('resize', apply)
|
||||||
}, [focusZone, autoResize])
|
}, [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.
|
// Flash the branch · repository banner for ~2s each time the window gains focus.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: ReturnType<typeof setTimeout>
|
let timer: ReturnType<typeof setTimeout>
|
||||||
@@ -412,58 +454,90 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- context menus ----
|
// ---- 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 {
|
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
|
||||||
e.preventDefault(); e.stopPropagation()
|
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) } })
|
openMenuAt(e.clientX, e.clientY, target)
|
||||||
if (target.kind === 'editor') {
|
}
|
||||||
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`
|
// ⌘→ inside Git/Explorer: open the selected row's menu, anchored to its row.
|
||||||
const mx = e.clientX, my = e.clientY
|
function openPanelMenu(): boolean {
|
||||||
setMenu({
|
if (activePanel === 'git' && gitSelPath) {
|
||||||
x: mx, y: my, note: ref,
|
const r = document.querySelector('.git-row.kbd')?.getBoundingClientRect()
|
||||||
items: [
|
openMenuAt(r ? r.right - 40 : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: proj.staged.has(gitSelPath) })
|
||||||
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
|
return true
|
||||||
{ 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 })
|
|
||||||
}
|
}
|
||||||
|
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
|
// ⌘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() ?? '')
|
: (window.getSelection()?.toString() ?? '')
|
||||||
return raw.split('\n')[0].trim()
|
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<HTMLElement>('.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).
|
// Returns true when a selection was found (so we can swallow the key).
|
||||||
function passSelection(): boolean {
|
function passSelection(): boolean {
|
||||||
if (!active) return false
|
if (!active) return false
|
||||||
@@ -507,20 +594,22 @@ export function App(): React.ReactElement {
|
|||||||
const s = v.slice(0, ae.selectionStart).split('\n').length
|
const s = v.slice(0, ae.selectionStart).split('\n').length
|
||||||
const en = v.slice(0, ae.selectionEnd).split('\n').length
|
const en = v.slice(0, ae.selectionEnd).split('\n').length
|
||||||
const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}`
|
const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}`
|
||||||
|
const code = v.slice(ae.selectionStart, ae.selectionEnd)
|
||||||
const r = ae.getBoundingClientRect()
|
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
|
return true
|
||||||
}
|
}
|
||||||
// Diff / Original (PaneView) — line range tracked in `selection` state.
|
// Diff / Original (PaneView) — line range tracked in `selection` state.
|
||||||
if (selection && selection.path === active && selection.start !== selection.end) {
|
if (selection && selection.path === active && selection.start !== selection.end) {
|
||||||
const ref = `${active}:${selection.start}-${selection.end}`
|
const ref = `${active}:${selection.start}-${selection.end}`
|
||||||
|
const code = codeFromDom(selection.start, selection.end)
|
||||||
const dom = window.getSelection()
|
const dom = window.getSelection()
|
||||||
let x = window.innerWidth / 2, y = 150
|
let x = window.innerWidth / 2, y = 150
|
||||||
if (dom && dom.rangeCount && !dom.isCollapsed) {
|
if (dom && dom.rangeCount && !dom.isCollapsed) {
|
||||||
const rr = dom.getRangeAt(0).getBoundingClientRect()
|
const rr = dom.getRangeAt(0).getBoundingClientRect()
|
||||||
if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 }
|
if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 }
|
||||||
}
|
}
|
||||||
setPassPopup({ x, y, ref })
|
setPassPopup({ x, y, ref, code })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -541,6 +630,22 @@ export function App(): React.ReactElement {
|
|||||||
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
|
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
|
||||||
// The history navigator owns the keyboard while open (it listens in capture phase).
|
// The history navigator owns the keyboard while open (it listens in capture phase).
|
||||||
if (overlay === 'history') return
|
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 (e.key === 'Escape') {
|
||||||
if (splitFor) setSplitFor(null)
|
if (splitFor) setSplitFor(null)
|
||||||
else if (overlay) setOverlay(null)
|
else if (overlay) setOverlay(null)
|
||||||
@@ -564,8 +669,12 @@ export function App(): React.ReactElement {
|
|||||||
// ⌘D deletes the current file (with confirmation).
|
// ⌘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() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
|
||||||
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
||||||
// ⌘→ with a text selection passes that selection to the agent (else native nav).
|
// ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
|
||||||
else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() }
|
// 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).
|
// ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself).
|
||||||
else if (meta && e.key === 'Enter') {
|
else if (meta && e.key === 'Enter') {
|
||||||
if (ae && ae.classList.contains('commit-input')) return
|
if (ae && ae.classList.contains('commit-input')) return
|
||||||
@@ -589,7 +698,7 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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')
|
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||||
|
|
||||||
@@ -647,25 +756,29 @@ export function App(): React.ReactElement {
|
|||||||
|
|
||||||
{/* workbench */}
|
{/* workbench */}
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
|
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '')} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
|
||||||
|
onMouseDownCapture={() => setActivePanel('git')}>
|
||||||
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
||||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||||
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
|
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
|
||||||
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} showDir={gitW > 300} />
|
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
|
||||||
|
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
||||||
|
|
||||||
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}>
|
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '')} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
|
||||||
|
onMouseDownCapture={() => setActivePanel('tree')}>
|
||||||
{proj.tree ? (
|
{proj.tree ? (
|
||||||
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
||||||
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
|
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
|
||||||
|
kbdPath={activePanel === 'tree' ? (treeSelItem?.path ?? null) : null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
|
||||||
) : (
|
) : (
|
||||||
<div className="tree-body" />
|
<div className="tree-body" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
||||||
|
|
||||||
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}>
|
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '')} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
|
||||||
<Editor active={active} mode={mode}
|
<Editor active={active} mode={mode}
|
||||||
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
|
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
|
||||||
onContext={openMenu}
|
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 */}
|
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
|
||||||
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />}
|
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
|
||||||
|
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* overlays */}
|
{/* overlays */}
|
||||||
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
||||||
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
|
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
|
||||||
onConfirm={(text) => {
|
onConfirm={(payload) => {
|
||||||
const line = (text && text.trim() ? text.trim() + ' ' : '') + passPopup.ref
|
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
|
||||||
window.dispatchEvent(new CustomEvent('agentPaste', { detail: line }))
|
|
||||||
setPassPopup(null)
|
setPassPopup(null)
|
||||||
toast('Passed to agent', passPopup.ref)
|
toast('Passed to agent', passPopup.ref)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -61,15 +61,17 @@ export interface ContextTarget {
|
|||||||
staged?: boolean
|
staged?: boolean
|
||||||
sel?: { start: number; end: number }
|
sel?: { start: number; end: number }
|
||||||
line?: number
|
line?: number
|
||||||
|
code?: string
|
||||||
}
|
}
|
||||||
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
||||||
|
|
||||||
/* ============ Git / Source Control panel ============ */
|
/* ============ 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
|
c: Change
|
||||||
staged: boolean
|
staged: boolean
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
ctxPath: string | null
|
ctxPath: string | null
|
||||||
|
kbdPath: string | null
|
||||||
showDir: boolean
|
showDir: boolean
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
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 dir = c.path.split('/').slice(0, -1).join('/')
|
||||||
const dirShown = showDir && !!dir
|
const dirShown = showDir && !!dir
|
||||||
return (
|
return (
|
||||||
<div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '')}
|
<div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdPath === c.path ? ' kbd' : '')}
|
||||||
onClick={() => onOpen(c.path, { diff: true })}
|
onClick={() => onOpen(c.path, { diff: true })}
|
||||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
||||||
title={c.path}>
|
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
|
branch: string
|
||||||
changes: Change[]
|
changes: Change[]
|
||||||
staged: Set<string>
|
staged: Set<string>
|
||||||
@@ -111,6 +113,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
ctxPath: string | null
|
ctxPath: string | null
|
||||||
|
kbdPath: string | null
|
||||||
showDir: boolean
|
showDir: boolean
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const visible = changes.filter((c) => !committed.has(c.path))
|
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 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
|
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
|
||||||
</div>
|
</div>
|
||||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
{stagedList.length > 0 ? stagedList.map((c) => (
|
||||||
<GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} showDir={showDir}
|
<GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir}
|
||||||
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||||
)) : (
|
)) : (
|
||||||
<div className="git-none">Nothing staged — use <span className="key">+</span> to stage a file</div>
|
<div className="git-none">Nothing staged — use <span className="key">+</span> to stage a file</div>
|
||||||
@@ -151,7 +154,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
|
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
|
||||||
</div>
|
</div>
|
||||||
{changesList.length > 0 ? changesList.map((c) => (
|
{changesList.length > 0 ? changesList.map((c) => (
|
||||||
<GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} showDir={showDir}
|
<GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir}
|
||||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||||
)) : (
|
)) : (
|
||||||
<div className="git-none">All changes staged</div>
|
<div className="git-none">All changes staged</div>
|
||||||
@@ -172,7 +175,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ============ File Tree ============ */
|
/* ============ 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
|
node: FileNode
|
||||||
depth: number
|
depth: number
|
||||||
openDirs: Set<string>
|
openDirs: Set<string>
|
||||||
@@ -181,6 +184,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
ctxPath: string | null
|
ctxPath: string | null
|
||||||
|
kbdPath: string | null
|
||||||
changeMap: Record<string, GitStatus>
|
changeMap: Record<string, GitStatus>
|
||||||
committed: Set<string>
|
committed: Set<string>
|
||||||
showHidden: boolean
|
showHidden: boolean
|
||||||
@@ -191,7 +195,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
{node.path !== '' && (
|
{node.path !== '' && (
|
||||||
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '')} style={{ paddingLeft: pad }}
|
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
|
||||||
onClick={() => toggleDir(node.path)}
|
onClick={() => toggleDir(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
||||||
<span className="tw"><Chevron open={isOpen} /></span>
|
<span className="tw"><Chevron open={isOpen} /></span>
|
||||||
@@ -204,14 +208,14 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
.map((c) => (
|
.map((c) => (
|
||||||
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
||||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
||||||
onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
||||||
))}
|
))}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
||||||
return (
|
return (
|
||||||
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '')}
|
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
|
||||||
style={{ paddingLeft: pad + 2 }}
|
style={{ paddingLeft: pad + 2 }}
|
||||||
onClick={() => onOpen(node.path)}
|
onClick={() => onOpen(node.path)}
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
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
|
tree: FileNode
|
||||||
openDirs: Set<string>
|
openDirs: Set<string>
|
||||||
toggleDir: (path: string) => void
|
toggleDir: (path: string) => void
|
||||||
@@ -232,6 +236,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
ctxPath: string | null
|
ctxPath: string | null
|
||||||
|
kbdPath: string | null
|
||||||
changeMap: Record<string, GitStatus>
|
changeMap: Record<string, GitStatus>
|
||||||
committed: Set<string>
|
committed: Set<string>
|
||||||
showHidden: boolean
|
showHidden: boolean
|
||||||
@@ -240,7 +245,7 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
|
|||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="tree-body">
|
<div className="tree-body">
|
||||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
||||||
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
|||||||
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
|
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
|
||||||
if (ta.selectionEnd > ta.selectionStart) {
|
if (ta.selectionEnd > ta.selectionStart) {
|
||||||
const endLine = text.slice(0, ta.selectionEnd).split('\n').length
|
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)
|
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) }
|
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 {
|
function handleContext(e: React.MouseEvent): void {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const sel = window.getSelection()
|
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)
|
const f = sel && sel.focusNode && climbToLine(sel.focusNode)
|
||||||
if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) {
|
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!)
|
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) {
|
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
||||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
|
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
|
||||||
|
info.code = codeForRange(selection.start, selection.end)
|
||||||
} else {
|
} else {
|
||||||
let no: number | null = null
|
let no: number | null = null
|
||||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null
|
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ export interface MenuItem {
|
|||||||
}
|
}
|
||||||
export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
|
export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
|
||||||
export interface Toast { id: number; title: string; ref?: string }
|
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 ContentHit { no: number; ln: string; ix: number }
|
||||||
interface ContentGroup { path: string; hits: ContentHit[] }
|
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: '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 left (this file · project · names)' },
|
||||||
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
|
{ 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: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
|
||||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
{ 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 {
|
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
|
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('mousedown', h)
|
||||||
document.addEventListener('keydown', k)
|
document.addEventListener('keydown', k, true)
|
||||||
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k) }
|
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
|
||||||
}, [])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [items.length])
|
||||||
if (!menu) return null
|
if (!menu) return null
|
||||||
const x = Math.min(menu.x, window.innerWidth - 270)
|
const x = Math.min(menu.x, window.innerWidth - 270)
|
||||||
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60))
|
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: ()
|
|||||||
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
||||||
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
||||||
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
||||||
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '')}
|
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
|
||||||
|
onMouseEnter={() => setHi(i)}
|
||||||
onClick={() => { it.onClick?.(); onClose() }}>
|
onClick={() => { it.onClick?.(); onClose() }}>
|
||||||
<span className="ic">{it.icon}</span>
|
<span className="ic">{it.icon}</span>
|
||||||
<span>{it.label}</span>
|
<span>{it.label}</span>
|
||||||
@@ -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
|
x: number
|
||||||
y: number
|
y: number
|
||||||
refStr: string
|
refStr: string
|
||||||
onConfirm: (text: string) => void
|
code?: string
|
||||||
|
onConfirm: (payload: string) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const [text, setText] = useState('')
|
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 left = Math.min(x, window.innerWidth - 360)
|
||||||
const top = Math.min(y + 6, window.innerHeight - 150)
|
const top = Math.min(y + 6, window.innerHeight - 150)
|
||||||
const preview = (text.trim() ? text.trim() + ' ' : '') + refStr
|
const payload = buildPass(text, refStr, code)
|
||||||
return (
|
return (
|
||||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||||
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
||||||
@@ -481,10 +527,10 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
|||||||
placeholder="Add a note (optional)…"
|
placeholder="Add a note (optional)…"
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
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() }
|
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
|
||||||
}} />
|
}} />
|
||||||
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div>
|
<div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
|
||||||
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,8 +120,13 @@ body {
|
|||||||
.workbench { flex:1; display:flex; min-height:0; }
|
.workbench { flex:1; display:flex; min-height:0; }
|
||||||
|
|
||||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
|
.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); }
|
.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 { 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; }
|
.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.ctx .git-act { visibility:visible; }
|
||||||
.git-row.active { background:var(--sel); }
|
.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.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 { 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-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; }
|
.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:hover, .tree-row.ctx { background:var(--hover); }
|
||||||
.tree-row.active { background:var(--sel); }
|
.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.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; }
|
.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-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
.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 { 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 .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 { 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 { 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; }
|
.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 */
|
/* 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 { 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 { 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 .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 { color:var(--fg-0); }
|
||||||
.ctx-item.primary .ic { color:var(--accent); }
|
.ctx-item.primary .ic { color:var(--accent); }
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
const termRef = useRef<XTerm | null>(null)
|
const termRef = useRef<XTerm | null>(null)
|
||||||
const [, setLive] = useState(kind === 'agent')
|
const [, setLive] = useState(kind === 'agent')
|
||||||
const [menu, setMenu] = useState<Menu | null>(null)
|
const [menu, setMenu] = useState<Menu | null>(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(() => {
|
useEffect(() => {
|
||||||
const bridge = window.helder
|
const bridge = window.helder
|
||||||
@@ -75,7 +80,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
function onPaste(e: Event): void {
|
function onPaste(e: Event): void {
|
||||||
if (kind !== 'agent' || !bridge || id < 0) return
|
if (kind !== 'agent' || !bridge || id < 0) return
|
||||||
const text = (e as CustomEvent<string>).detail
|
const text = (e as CustomEvent<string>).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()
|
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) })
|
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) } })
|
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))
|
term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
|
||||||
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
|
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Pass on to Agent', () => {
|
describe('Pass on to Agent', () => {
|
||||||
it('inserts "<note> <path:line>" via the agentPaste event', async () => {
|
it('inserts "<note> => <path:line>" via the agentPaste event', async () => {
|
||||||
const received: string[] = []
|
const received: string[] = []
|
||||||
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
|
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
|
||||||
window.addEventListener('agentPaste', handler)
|
window.addEventListener('agentPaste', handler)
|
||||||
@@ -57,7 +57,7 @@ describe('Pass on to Agent', () => {
|
|||||||
})
|
})
|
||||||
fireEvent.contextMenu(ta)
|
fireEvent.contextMenu(ta)
|
||||||
const pass = await waitFor(() => {
|
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')
|
if (!item) throw new Error('menu not open')
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
@@ -70,7 +70,7 @@ describe('Pass on to Agent', () => {
|
|||||||
fireEvent.change(input, { target: { value: 'look here' } })
|
fireEvent.change(input, { target: { value: 'look here' } })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
await waitFor(() => expect(received.length).toBeGreaterThan(0))
|
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 {
|
} finally {
|
||||||
window.removeEventListener('agentPaste', handler)
|
window.removeEventListener('agentPaste', handler)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user