@@ -15,7 +15,7 @@
|
||||
},
|
||||
"files": {
|
||||
"exclude": [],
|
||||
"followGitignore": true
|
||||
"followGitignore": false
|
||||
},
|
||||
"terminal": {
|
||||
"shell": null
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ const api = {
|
||||
|
||||
clipboard: {
|
||||
writeText: (text: string) => clipboard.writeText(text),
|
||||
readText: (): string => clipboard.readText(),
|
||||
},
|
||||
|
||||
project: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>),
|
||||
|
||||
2
src/renderer/src/env.d.ts
vendored
2
src/renderer/src/env.d.ts
vendored
@@ -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 }>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user