fixes and build 0.1.0
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-09-04 09:43:04 +02:00
parent e5f739ec24
commit bccc97c11c
14 changed files with 233 additions and 60 deletions

View File

@@ -5,7 +5,9 @@
}, },
"editor": { "editor": {
"autoSave": false, "autoSave": false,
"tabSize": 4 "tabSize": 4,
"wordWrap": "markdown",
"copyOnSelect": true
}, },
"git": { "git": {
"confirmDiscard": true, "confirmDiscard": true,
@@ -19,7 +21,40 @@
"followGitignore": false "followGitignore": false
}, },
"terminal": { "terminal": {
"shell": null "shell": null,
"fontFamily": null,
"fontSize": null,
"lineHeight": 1.7,
"letterSpacing": 0,
"cursorStyle": "bar",
"cursorBlink": true,
"boldIsBright": false,
"scrollback": 8000,
"optionIsMeta": false,
"theme": {
"background": "#101720",
"foreground": "#E4E7E6",
"cursor": "#E8913A",
"cursorAccent": "#101720",
"selectionBackground": "rgba(232,145,58,0.22)",
"selectionInactiveBackground": "rgba(232,145,58,0.12)",
"black": "#232C39",
"red": "#C4741F",
"green": "#8FBFB4",
"yellow": "#F0B476",
"blue": "#8FA9C4",
"magenta": "#C3A6CE",
"cyan": "#8FC4C4",
"white": "#BAC0C0",
"brightBlack": "#6C7783",
"brightRed": "#E8913A",
"brightGreen": "#A6D2C7",
"brightYellow": "#F5C79A",
"brightBlue": "#A9BFD6",
"brightMagenta": "#D6BFDF",
"brightCyan": "#A9D6D6",
"brightWhite": "#F4F5F4"
}
}, },
"session": { "session": {
"restoreOnLaunch": true "restoreOnLaunch": true

View File

@@ -70,7 +70,7 @@
</div> </div>
<div style="display:flex;align-items:center;gap:4px;flex:none"> <div style="display:flex;align-items:center;gap:4px;flex:none">
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">A</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Fluid <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">L</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
@@ -558,7 +558,7 @@
<div style="width:640px;background:#18202B;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column"> <div style="width:640px;background:#18202B;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
<div style="height:44px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 12px;border-bottom:1px solid #232C39"> <div style="height:44px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 12px;border-bottom:1px solid #232C39">
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">A</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Fluid <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">L</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span> <span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
@@ -566,7 +566,7 @@
<sc-if value="{{ showTooltips }}" hint-placeholder-val="{{ true }}"> <sc-if value="{{ showTooltips }}" hint-placeholder-val="{{ true }}">
<div style="padding:14px 12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end"> <div style="padding:14px 12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end">
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Hidden files: <span style="color:#E8913A">hidden</span> — dotfiles excluded from the tree and the search. Click to show.</div> <div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Hidden files: <span style="color:#E8913A">hidden</span> — dotfiles excluded from the tree and the search. Click to show.</div>
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Auto-fit panels: <span style="color:#E8913A">on</span> — columns re-fit on resize and focus. Click to lock the current sizes.</div> <div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Column widths: <span style="color:#E8913A">fluid</span> the columns re-fit on resize and focus. Click to lock them.</div>
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Project note (.notes.txt) — kept next to this project.</div> <div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Project note (.notes.txt) — kept next to this project.</div>
</div> </div>
</sc-if> </sc-if>

View File

@@ -168,7 +168,7 @@ toolbar, right-aligned, gap 4px.
Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill
`rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`. `rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`.
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles: Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
Search ⌘F · Auto-fitA · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border). Search ⌘F · FluidL · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
**Source control column** (236px, `#18202B`, 1px right border) **Source control column** (236px, `#18202B`, 1px right border)
Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder
@@ -291,7 +291,7 @@ with amber glyphs.
**Tooltip**`#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px, **Tooltip**`#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px,
Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the
sentence in amber ("Hidden files: *hidden* — …", "Auto-fit panels: *on* — …"), then one sentence in amber ("Hidden files: *hidden* — …", "Column widths: *fluid* — …"), then one
sentence saying what a click does. sentence saying what a click does.
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's **Markdown Actual / Preview** — same 34px header on both halves; the segmented control's

Binary file not shown.

Binary file not shown.

View File

@@ -27,7 +27,7 @@ export interface TerminalTheme {
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap; copyOnSelect: boolean }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { terminal: {
@@ -54,7 +54,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = { export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown', copyOnSelect: true },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false }, files: { exclude: [], followGitignore: false },
terminal: { terminal: {

View File

@@ -1,5 +1,5 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */ /* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components' import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components'
import type { ContextTarget } from './components' import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor' import { Editor, SplitView } from './editor'
@@ -10,6 +10,7 @@ import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays' import type { Menu, Toast } from './overlays'
import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types' import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types'
import { useProject, useProjectActions } from './project' import { useProject, useProjectActions } from './project'
import { useCopyOnSelect } from './copy-on-select'
import { HL } from './highlight' import { HL } from './highlight'
import { loadSymbols } from './symbols' import { loadSymbols } from './symbols'
import { rlog } from './log' import { rlog } from './log'
@@ -17,6 +18,24 @@ import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set<string>() const NO_COMMITTED = new Set<string>()
/* Read-only panes carry no caret, so ⌘A has to name its own target: the pane
* that holds the current selection, else the file viewer. The gutter and the
* diff sign are user-select:none, so they stay out of the copied text. */
const SURFACES = '.editor, .md-view'
function selectAllSurface(): void {
const node = window.getSelection()?.anchorNode ?? null
const from = node && (node.nodeType === 1 ? (node as HTMLElement) : node.parentElement)
const el = from?.closest(SURFACES)
?? document.querySelector('.split-overlay .editor')
?? document.querySelector('.editor-wrap .editor, .editor-wrap .md-view')
if (!el) return
const range = document.createRange()
range.selectNodeContents(el)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement { function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement {
const [drag, setDrag] = useState(false) const [drag, setDrag] = useState(false)
function down(e: React.MouseEvent): void { function down(e: React.MouseEvent): void {
@@ -48,7 +67,7 @@ function RightColumn({ width, active, onFocus }: { width: number; active: boolea
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' + (active ? ' panel-active' : '')} style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}> <div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 1 ' + 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" />
@@ -188,6 +207,8 @@ export function App(): React.ReactElement {
// 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 = 1600 const FOCUS_RESIZE_BELOW = 1600
const SPLITTER_W = 1 // .splitter is a 1px flex track
const MIN_EDITOR_W = 280 // Col C never drops below this
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 // Which column currently has focus — drives the active-panel tint and keyboard
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu). // navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
@@ -200,7 +221,7 @@ export function App(): React.ReactElement {
// 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).
const [autoResize, setAutoResize] = useState(true) const [fluid, setFluid] = useState(true)
const [showHidden, setShowHidden] = useState(false) const [showHidden, setShowHidden] = useState(false)
// Col A (Git) visibility. On by default; the title-bar GIT toggle hides the // Col A (Git) visibility. On by default; the title-bar GIT toggle hides the
// whole column and its splitter, and the editor takes the free width. // whole column and its splitter, and the editor takes the free width.
@@ -209,7 +230,7 @@ export function App(): React.ReactElement {
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15)) const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3)) const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
useEffect(() => { useEffect(() => {
if (!autoResize) return if (!fluid) return
function apply(): void { function apply(): void {
const w = window.innerWidth const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) { if (w >= FOCUS_RESIZE_BELOW) {
@@ -226,7 +247,29 @@ export function App(): React.ReactElement {
apply() apply()
window.addEventListener('resize', apply) window.addEventListener('resize', apply)
return () => window.removeEventListener('resize', apply) return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize]) }, [focusZone, fluid])
// Keep the stored widths inside the window. Col A, B and D are pixel tracks and
// only Col C absorbs the remainder, so a total wider than the window leaves the
// state disagreeing with what CSS shows (the columns carry flex-shrink 1 as the
// net) and the next splitter drag jumps. The budget reads window.innerWidth
// live, never a state copy: a resize sets the widths and the window size in the
// same batch, and a stale copy would scale the new widths against the old size.
const [winBump, bumpWin] = useState(0)
useEffect(() => {
const onResize = (): void => bumpWin((n) => n + 1)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
useLayoutEffect(() => {
const budget = window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W - MIN_EDITOR_W
const total = (showGit ? gitW : 0) + treeW + rightW
if (total <= budget || total <= 0) return
const k = Math.max(0, budget) / total
if (showGit) setGitW((w) => Math.floor(w * k))
setTreeW((w) => Math.floor(w * k))
setRightW((w) => Math.floor(w * k))
}, [winBump, showGit, gitW, treeW, rightW])
// A hidden Col A must not keep the focus ring or the arrow-key cursor. // A hidden Col A must not keep the focus ring or the arrow-key cursor.
useEffect(() => { useEffect(() => {
@@ -387,6 +430,8 @@ export function App(): React.ReactElement {
saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide }) saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide })
}, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch]) }, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch])
useCopyOnSelect(proj.config.editor.copyOnSelect)
function toast(title: string, ref?: string): void { function toast(title: string, ref?: string): void {
const id = lid() const id = lid()
setToasts((t) => [...t, { id, title, ref }]) setToasts((t) => [...t, { id, title, ref }])
@@ -613,23 +658,6 @@ export function App(): React.ReactElement {
requestAnimationFrame(place) requestAnimationFrame(place)
} }
// Close the current file view (no tabs anymore — the recent-files list replaces
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
async function closeTab(path: string): Promise<void> {
const buf = buffersRef.current[path]
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
if (dirtyNow) {
const bridge = window.helder
const choice = bridge
? await bridge.dialog.unsavedClose(path)
: (window.confirm(`Discard unsaved changes to ${path}?`) ? 'discard' : 'cancel')
if (choice === 'cancel') return
if (choice === 'save') writeToDisk(path, buf as string)
}
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
if (path === active) setActive(null)
}
// ---- context menus ---- // ---- context menus ----
// Naming contract: every "Pass on …" action opens the input popup (so the user // 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, // can attach a note), and its "Copy …" twin sits directly below it. Pass first,
@@ -898,9 +926,6 @@ export function App(): React.ReactElement {
// ⌘N opens the project note. // ⌘N opens the project note.
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') } else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() } else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
// ⌘D deletes the current file (with confirmation).
else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() } else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
// ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes // ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
// the current text selection to the agent (else native nav). // the current text selection to the agent (else native nav).
@@ -919,10 +944,16 @@ export function App(): React.ReactElement {
if (hasSelection()) return if (hasSelection()) return
e.preventDefault(); focusCommit() e.preventDefault(); focusCommit()
} }
// ⌘A toggles auto-fit (but let native select-all run inside text fields). // ⌘A selects all of one surface. A text field has its own select-all;
// outside one we build the range ourselves, because the browser default
// would mark every panel and label in the window.
else if (meta && e.key.toLowerCase() === 'a') { else if (meta && e.key.toLowerCase() === 'a') {
if (inField) return if (inField) return
e.preventDefault(); setAutoResize((v) => !v) e.preventDefault(); selectAllSurface()
}
// ⌘L makes the columns fluid again, or locks them at their current width.
else if (meta && e.key.toLowerCase() === 'l') {
e.preventDefault(); setFluid((v) => !v)
} }
// ⌘. toggles hidden files. Match on e.code so it fires regardless of layout. // ⌘. toggles hidden files. Match on e.code so it fires regardless of layout.
else if (meta && e.code === 'Period') { else if (meta && e.code === 'Period') {
@@ -1025,11 +1056,11 @@ export function App(): React.ReactElement {
Search <kbd>F</kbd> Search <kbd>F</kbd>
</button> </button>
</Tip> </Tip>
<Tip text={autoResize <Tip text={fluid
? <>Auto-fit panels: <b>on</b> columns re-fit on resize and focus. Click to lock the current sizes.</> ? <>Column widths: <b>fluid</b> the columns re-fit on resize and focus. Click to lock them.</>
: <>Auto-fit panels: <b>off</b> the sizes are locked. Click to re-enable.</>}> : <>Column widths: <b>locked</b> the columns keep their size. Click to make them fluid.</>}>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}> <button className={'tb-btn tb-toggle' + (fluid ? ' on' : '')} onClick={() => setFluid((v) => !v)}>
Auto-fit <kbd>A</kbd> Fluid <kbd>L</kbd>
</button> </button>
</Tip> </Tip>
<Tip text={showHidden <Tip text={showHidden
@@ -1072,7 +1103,7 @@ export function App(): React.ReactElement {
{/* workbench */} {/* workbench */}
<div className="workbench"> <div className="workbench">
{showGit && (<> {showGit && (<>
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }} <div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 1 ' + gitW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }} onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}
onMouseOver={(e) => syncGitSel(e.target)}> onMouseOver={(e) => syncGitSel(e.target)}>
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED} <GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
@@ -1081,10 +1112,10 @@ export function App(): React.ReactElement {
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null} onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} /> kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> <Splitter onDelta={(dx) => { setFluid(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
</>)} </>)}
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }} <div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 1 ' + treeW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }} onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}
onMouseOver={(e) => syncTreeSel(e.target)}> onMouseOver={(e) => syncTreeSel(e.target)}>
<div className="phead">Explorer<span className="ct">{proj.name}</span></div> <div className="phead">Explorer<span className="ct">{proj.name}</span></div>
@@ -1096,7 +1127,7 @@ export function App(): React.ReactElement {
<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) => { setFluid(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}> <div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} side={activeSide} <Editor active={active} mode={mode} side={activeSide}
@@ -1107,10 +1138,11 @@ export function App(): React.ReactElement {
flash={lineFlash && lineFlash.path === active ? lineFlash : null} flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} /> bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => { <Splitter onDelta={(dx) => { setFluid(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap) // grow until the editor would drop below ~280px (rather than a fixed cap)
const max = Math.max(280, window.innerWidth - (showGit ? gitW : 0) - treeW - 280) const max = Math.max(MIN_EDITOR_W, window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W
return clamp(w - dx, 280, max) - (showGit ? gitW : 0) - treeW - MIN_EDITOR_W)
return clamp(w - dx, MIN_EDITOR_W, max)
}) }} /> }) }} />
{/* 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 */}

View File

@@ -133,15 +133,18 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext,
) )
} }
/** Split a change list into folder groups, keeping the incoming order. The /** Split a change list into folder groups, in order of first appearance. The
* keyboard cursor walks the same order, so App must flatten with this too. */ * keyboard cursor walks the same order, so App must flatten with this too.
* git status is not path-sorted, so a folder comes back in separated runs. One
* group per folder keeps the header unique, and with it the React key. */
export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] { export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] {
const out: { dir: string; rows: Change[] }[] = [] const out: { dir: string; rows: Change[] }[] = []
const byDir = new Map<string, { dir: string; rows: Change[] }>()
for (const c of list) { for (const c of list) {
const dir = c.path.split('/').slice(0, -1).join('/') const dir = c.path.split('/').slice(0, -1).join('/')
const last = out[out.length - 1] const group = byDir.get(dir)
if (last && last.dir === dir) last.rows.push(c) if (group) group.rows.push(c)
else out.push({ dir, rows: [c] }) else { const g = { dir, rows: [c] }; byDir.set(dir, g); out.push(g) }
} }
return out return out
} }

View File

@@ -0,0 +1,52 @@
/* Copy-on-select: a settled selection lands on the clipboard with no keystroke. */
import { useEffect } from 'react'
import { rlog } from './log'
/** Text of the settled selection, or '' when there is nothing to copy.
* A focused input or textarea keeps its own selection out of the DOM
* selection, so read it directly — but only for the code editor. Elsewhere
* (search box, commit message) a selection is the start of an edit, and the
* clipboard must stay as it is. */
function selectedText(): string {
const el = document.activeElement as HTMLTextAreaElement | HTMLInputElement | null
if (el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) {
if (!el.classList.contains('ce-ta')) return ''
const from = el.selectionStart ?? 0
const to = el.selectionEnd ?? 0
return from === to ? '' : el.value.slice(from, to)
}
return window.getSelection()?.toString() ?? ''
}
function write(text: string): void {
const bridge = window.helder
if (bridge && bridge.clipboard) { bridge.clipboard.writeText(text); return }
navigator.clipboard?.writeText(text).catch((e) => rlog.warn('copy-on-select', 'clipboard write failed', String(e)))
}
/** Copies whatever the user selects, the way both terminal panes already do.
* The mouse and the keyboard both settle a selection, so listen for the end of
* the gesture instead of `selectionchange`, which fires on every pixel of a
* drag. */
export function useCopyOnSelect(enabled: boolean): void {
useEffect(() => {
if (!enabled) return
let last = ''
function settle(): void {
const text = selectedText()
if (!text.trim() || text === last) return
last = text
write(text)
}
function onKeyUp(e: KeyboardEvent): void {
const selectAll = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a'
if (e.shiftKey || selectAll) settle()
}
document.addEventListener('mouseup', settle)
document.addEventListener('keyup', onKeyUp)
return () => {
document.removeEventListener('mouseup', settle)
document.removeEventListener('keyup', onKeyUp)
}
}, [enabled])
}

View File

@@ -501,10 +501,9 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[]
group: 'File', group: 'File',
rows: [ rows: [
{ keys: ['⌘', 'S'], label: 'Save this file' }, { keys: ['⌘', 'S'], label: 'Save this file' },
{ keys: ['⌘', 'W'], label: 'Close this file' },
{ keys: ['⌘', 'D'], label: 'Delete this file' },
{ keys: ['⌘', '.'], label: 'Hidden files on or off' }, { keys: ['⌘', '.'], label: 'Hidden files on or off' },
{ keys: ['⌘', 'A'], label: 'Auto-fit panels on or off' }, { keys: ['⌘', 'A'], label: 'Select all in this pane' },
{ keys: ['⌘', 'L'], label: 'Fluid columns on or off' },
{ keys: ['Esc'], label: 'Close an overlay or a split view' }, { keys: ['Esc'], label: 'Close an overlay or a split view' },
], ],
}, },

View File

@@ -169,7 +169,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); } .tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); }
.tb-toggle.muted:hover { color:var(--fg-2); } .tb-toggle.muted:hover { color:var(--fg-2); }
.workbench { flex:1; display:flex; min-height:0; } .workbench { flex:1; display:flex; min-height:0; overflow:hidden; }
/* --col-bg holds each column's resting background so the ⌘R flash below can /* --col-bg holds each column's resting background so the ⌘R flash below can
animate back to it, whichever state the column is in. */ animate back to it, whichever state the column is in. */
@@ -500,7 +500,10 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);} .ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
/* ============ terminals (right column) ============ */ /* ============ terminals (right column) ============ */
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); } /* min-width:0 is load-bearing: the pane is a row-flex item, so min-width:auto
would be the xterm's own rendered width. The pane could then grow with the
column but never shrink, and the terminal would keep the wider cols. */
.term-pane { display:flex; flex-direction:column; min-width:0; min-height:0; background:var(--bg-1); }
/* 30px panel header, same label as every other column. A live pane marks itself /* 30px panel header, same label as every other column. A live pane marks itself
with an amber dot; nothing pulses. */ with an amber dot; nothing pulses. */
.term-head { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px; padding:0 12px; background:var(--bg-2); border-bottom:1px solid var(--border); .term-head { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px; padding:0 12px; background:var(--bg-2); border-bottom:1px solid var(--border);

View File

@@ -81,6 +81,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
if (sel) bridge.clipboard.writeText(sel) if (sel) bridge.clipboard.writeText(sel)
}) })
} }
// ⌘A marks the whole buffer. xterm types on a hidden textarea, so the
// browser default would select that instead of the terminal's own text.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown' || !e.metaKey || e.ctrlKey || e.altKey) return true
if (e.key.toLowerCase() !== 'a') return true
e.preventDefault()
term.selectAll()
return false
})
try { fit.fit() } catch { /* host not measured yet */ } try { fit.fit() } catch { /* host not measured yet */ }
let disposed = false let disposed = false
@@ -119,6 +128,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
bridge.pty.write(id, d) 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))
// create() carried the size measured before this promise resolved. A fit
// in between changed xterm only, and onResize fires on a change, never on
// the current value, so the PTY would keep the stale width and the agent
// would wrap at more columns than the pane shows.
bridge.pty.resize(id, term.cols, term.rows)
if (kind === 'agent') window.addEventListener('agentPaste', onPaste) if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
}) })
} else { } else {

View File

@@ -98,7 +98,7 @@ export interface TerminalTheme {
/** Effective project settings (mirrors src/main/config.ts). */ /** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap } editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap; copyOnSelect: boolean }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { terminal: {
@@ -125,7 +125,7 @@ export interface HelderConfig {
export const DEFAULT_CONFIG: HelderConfig = { export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' }, editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown', copyOnSelect: true },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true }, files: { exclude: [], followGitignore: true },
terminal: { terminal: {

35
test/group-by-dir.test.ts Normal file
View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { groupByDir } from '../src/renderer/src/components'
import type { Change } from '../src/renderer/src/types'
function change(path: string): Change {
return { path, status: 'M', add: 1, del: 0, deleted: false, staged: false, id: path }
}
describe('groupByDir', () => {
it('gives one group per folder when git returns the folder in separated runs', () => {
const groups = groupByDir([
change('tests/Feature/AlphaTest.php'),
change('app/Models/User.php'),
change('tests/Feature/BetaTest.php'),
])
expect(groups.map((g) => g.dir)).toEqual(['tests/Feature', 'app/Models'])
expect(groups[0].rows.map((r) => r.path)).toEqual([
'tests/Feature/AlphaTest.php',
'tests/Feature/BetaTest.php',
])
})
it('keeps every row, so the keyboard cursor walks the full list', () => {
const list = ['a/one.ts', 'b/two.ts', 'a/three.ts', 'four.ts'].map(change)
const flat = groupByDir(list).flatMap((g) => g.rows)
expect(flat).toHaveLength(list.length)
expect(new Set(flat.map((r) => r.id)).size).toBe(list.length)
})
it('puts a project-root file in the empty-string group', () => {
expect(groupByDir([change('README.md')])).toEqual([
{ dir: '', rows: [change('README.md')] },
])
})
})