diff --git a/.helder/config.default.json b/.helder/config.default.json index 3a7da5e..3fdcbb9 100644 --- a/.helder/config.default.json +++ b/.helder/config.default.json @@ -15,7 +15,7 @@ }, "files": { "exclude": [], - "followGitignore": true + "followGitignore": false }, "terminal": { "shell": null diff --git a/src/main/config.ts b/src/main/config.ts index 5f234b4..ce0b151 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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 }, } diff --git a/src/main/index.ts b/src/main/index.ts index 645f976..c740e1c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/preload/index.ts b/src/preload/index.ts index f97b7ef..97833d4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ const api = { clipboard: { writeText: (text: string) => clipboard.writeText(text), + readText: (): string => clipboard.readText(), }, project: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 82996a2..2f394fc 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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): Set { - 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(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 */}
-
{Icon.spark({ style: { color: 'var(--accent)' } })}Helder - actions.openFolder()}>{proj.name} +
+ actions.openFolder()}>{proj.name} + {proj.branch}
{active && (
@@ -535,10 +531,6 @@ export function App(): React.ReactElement { {isDirty(active) && }
)} -
- {Icon.branch()}{proj.branch} - -{proj.name} -
diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index d7708d4..2e7af6d 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -12,6 +12,7 @@ export const Icon: Record React.ReactElement> = { close: (p) => (), copy: (p) => (), terminal: (p) => (), + paste: (p) => (), spark: (p) => (), file: (p) => (), reveal: (p) => (), diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 0e4be7e..f4a96b3 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -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 }> diff --git a/src/renderer/src/terminals.tsx b/src/renderer/src/terminals.tsx index f1aed5c..d55abef 100644 --- a/src/renderer/src/terminals.tsx +++ b/src/renderer/src/terminals.tsx @@ -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(null) + const termRef = useRef(null) const [, setLive] = useState(kind === 'agent') + const [menu, setMenu] = useState(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 ( -
hostRef.current?.querySelector('textarea')?.focus()}> +
hostRef.current?.querySelector('textarea')?.focus()} + onContextMenu={onContextMenu}>
+ {menu && setMenu(null)} />}
) } diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index ea3a216..cf36ef1 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -23,6 +23,18 @@ function renderApp(): HTMLElement { function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined { return Array.from(c.querySelectorAll(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 { + 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 " " 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') diff --git a/test/app.test.tsx b/test/app.test.tsx index 8ed9521..3d2a09e 100644 --- a/test/app.test.tsx +++ b/test/app.test.tsx @@ -33,6 +33,18 @@ function renderApp(): HTMLElement { function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined { return Array.from(container.querySelectorAll(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 { + 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') diff --git a/test/fs.test.ts b/test/fs.test.ts index 9eaf697..ba5c425 100644 --- a/test/fs.test.ts +++ b/test/fs.test.ts @@ -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 }) })