copy paste
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-17 13:40:49 +02:00
parent 16296a27da
commit 42defcc7cd
11 changed files with 104 additions and 22 deletions

View File

@@ -15,7 +15,7 @@
},
"files": {
"exclude": [],
"followGitignore": true
"followGitignore": false
},
"terminal": {
"shell": null

View File

@@ -25,7 +25,7 @@ export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' },
files: { exclude: [], followGitignore: true },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },
session: { restoreOnLaunch: true },
}

View File

@@ -141,7 +141,31 @@ function buildAppMenu(): Menu {
],
},
{ role: 'editMenu' },
{ role: 'viewMenu' },
{
label: 'View',
submenu: [
// ⌘R refreshes git status + the file explorer instead of reloading the
// window. We send the same "project changed" ping the disk watchers use,
// which makes the renderer re-read git + the file tree. Reload / Force
// Reload are intentionally omitted so ⌘R never blows away app state.
{
label: 'Refresh',
accelerator: 'CmdOrCtrl+R',
click: (_m, win) => {
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
bw?.webContents.send('project:changed')
},
},
{ type: 'separator' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{ role: 'windowMenu' },
]
return Menu.buildFromTemplate(template)

View File

@@ -10,6 +10,7 @@ const api = {
clipboard: {
writeText: (text: string) => clipboard.writeText(text),
readText: (): string => clipboard.readText(),
},
project: {

View File

@@ -65,13 +65,6 @@ function ancestors(path: string): string[] {
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'))
return out
}
function initialOpenDirs(node: FileNode, set: Set<string>): Set<string> {
if (node.type === 'dir') {
if (node.open && node.path) set.add(node.path)
;(node.children || []).forEach((c) => initialOpenDirs(c, set))
}
return set
}
export function App(): React.ReactElement {
const proj = useProject()
@@ -187,12 +180,14 @@ export function App(): React.ReactElement {
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) }
}, [])
// Seed explorer expansion from the tree's `open` flags once per opened project.
// Open/reopen a project with a fully collapsed tree: seed the expansion set
// empty once per opened project (the root row is always shown regardless).
// A refresh keeps the user's expansion since seededRoot guards on proj.root.
const seededRoot = useRef<string | null | undefined>(undefined)
useEffect(() => {
if (proj.tree && seededRoot.current !== proj.root) {
seededRoot.current = proj.root
setOpenDirs(initialOpenDirs(proj.tree, new Set()))
setOpenDirs(new Set())
}
}, [proj.tree, proj.root])
@@ -526,8 +521,9 @@ export function App(): React.ReactElement {
{/* title bar */}
<div className="titlebar">
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title">{Icon.spark({ style: { color: 'var(--accent)' } })}<b>Helder</b><span style={{ color: 'var(--fg-3)' }}></span>
<span style={{ color: 'var(--fg-2)', cursor: 'pointer' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</span>
<div className="tb-title">
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
<span style={{ color: 'var(--fg-3)' }}>{proj.branch}</span>
</div>
{active && (
<div className="tb-crumb">
@@ -535,10 +531,6 @@ export function App(): React.ReactElement {
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div>
)}
<div className="tb-repo" title={proj.branch + ' - ' + proj.name}>
{Icon.branch()}<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span><span className="tb-repo-name">{proj.name}</span>
</div>
<div className="tb-spacer" />
<div className="tb-actions">
<button className="tb-btn" onClick={() => { setSearchInit(''); setOverlay('search') }}>{Icon.search()} Search <kbd>F</kbd></button>

View File

@@ -12,6 +12,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
close: (p) => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none" {...p}><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>),
copy: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.3" fill="none" /></svg>),
terminal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>),
paste: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="3" y="3" width="10" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3V2.2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V3" stroke="currentColor" strokeWidth="1.3" fill="none" /></svg>),
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round" /></svg>),
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),

View File

@@ -11,7 +11,7 @@ interface GitChangeRaw {
interface HelderBridge {
platform: string
clipboard: { writeText: (text: string) => void }
clipboard: { writeText: (text: string) => void; readText: () => string }
project: {
current: () => Promise<{ root: string | null; name: string }>
open: () => Promise<{ root: string | null; name: string }>

View File

@@ -8,6 +8,9 @@ import React, { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { ContextMenu } from './overlays'
import type { Menu } from './overlays'
import { Icon } from './components'
let _lid = 0
export const lid = (): number => ++_lid
@@ -29,7 +32,9 @@ const THEME = {
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerm | null>(null)
const [, setLive] = useState(kind === 'agent')
const [menu, setMenu] = useState<Menu | null>(null)
useEffect(() => {
const bridge = window.helder
@@ -48,9 +53,18 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
scrollback: 5000,
allowProposedApi: true,
})
termRef.current = term
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
// Both panes (D1 agent + D2 shell): selecting text auto-copies it to the clipboard.
if (bridge) {
term.onSelectionChange(() => {
const sel = term.getSelection()
if (sel) bridge.clipboard.writeText(sel)
})
}
try { fit.fit() } catch { /* host not measured yet */ }
let disposed = false
@@ -95,14 +109,38 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
offExit()
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id)
termRef.current = null
term.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
function onContextMenu(e: React.MouseEvent): void {
const bridge = window.helder
const term = termRef.current
if (!bridge || !term) return
e.preventDefault()
const items: Menu['items'] = []
if (term.hasSelection()) {
items.push({ icon: Icon.copy(), label: 'Copy', onClick: () => bridge.clipboard.writeText(term.getSelection()) })
}
items.push({
icon: Icon.paste(), label: 'Paste', onClick: () => {
const text = bridge.clipboard.readText()
if (text) term.paste(text)
term.focus()
},
})
setMenu({ x: e.clientX, y: e.clientY, items })
}
return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}>
<div className="term-pane" style={{ flex: 1, minHeight: 0 }}
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
onContextMenu={onContextMenu}>
<div className="term-xterm" ref={hostRef} />
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
</div>
)
}

View File

@@ -23,6 +23,18 @@ function renderApp(): HTMLElement {
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
}
// The tree opens fully collapsed, so expand each ancestor folder before reaching
// a nested file. Each folder is clicked exactly once (a second click collapses).
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
for (const name of folders) {
const row = await waitFor(() => {
const r = find(c, '.tree-row', name)
if (!r) throw new Error(`folder ${name} not ready`)
return r
})
fireEvent.click(row)
}
}
describe('Pass on to Agent', () => {
it('inserts "<note> <path:line>" via the agentPaste event', async () => {
@@ -31,6 +43,7 @@ describe('Pass on to Agent', () => {
window.addEventListener('agentPaste', handler)
try {
const c = renderApp()
await expandTo(c, 'public', 'assets')
const treeRow = await waitFor(() => {
const r = find(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready')

View File

@@ -33,6 +33,18 @@ function renderApp(): HTMLElement {
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text))
}
// The tree opens fully collapsed, so expand each ancestor folder before reaching
// a nested file. Each folder is clicked exactly once (a second click collapses).
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
for (const name of folders) {
const row = await waitFor(() => {
const r = rowWithText(c, '.tree-row', name)
if (!r) throw new Error(`folder ${name} not ready`)
return r
})
fireEvent.click(row)
}
}
describe('App (mock data, jsdom)', () => {
it('renders the four-column workbench with the git change list', async () => {
@@ -62,6 +74,7 @@ describe('App (mock data, jsdom)', () => {
it('makes an edited buffer dirty (breadcrumb dot)', async () => {
const c = renderApp()
await expandTo(c, 'public', 'assets')
const treeRow = await waitFor(() => {
const r = rowWithText(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready')

View File

@@ -47,7 +47,7 @@ describe('readAll', () => {
expect(files['src/a.ts']).toContain('export const a')
})
it('honors .gitignore in a repo (files.followGitignore default on)', async () => {
it('shows .gitignored files in a repo (files.followGitignore default off)', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await simpleGit(dir).init()
await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
@@ -55,7 +55,7 @@ describe('readAll', () => {
await writeFile(join(dir, 'keep.txt'), 'ok')
const keys = Object.keys(await readAll(dir))
expect(keys).toContain('keep.txt')
expect(keys).not.toContain('secret.txt')
expect(keys).toContain('secret.txt') // gitignore not followed by default → shown
})
})