Hidden files: hidden — dotfiles excluded from the tree and the search. Click to show.
-
Auto-fit panels: on — columns re-fit on resize and focus. Click to lock the current sizes.
+
Column widths: fluid — the columns re-fit on resize and focus. Click to lock them.
Project note (.notes.txt) — kept next to this project.
diff --git a/docs/design/README.md b/docs/design/README.md
index e052c2a..eadb410 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -168,7 +168,7 @@ toolbar, right-aligned, gap 4px.
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)`.
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
-Search ⌘F · Auto-fit ⌘A · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
+Search ⌘F · Fluid ⌘L · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
**Source control column** (236px, `#18202B`, 1px right border)
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,
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.
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's
diff --git a/releases/Helder-0.1.0-arm64.dmg b/releases/Helder-0.1.0-arm64.dmg
new file mode 100644
index 0000000..638c2c0
Binary files /dev/null and b/releases/Helder-0.1.0-arm64.dmg differ
diff --git a/releases/helder-win-v0.1.0.zip b/releases/helder-win-v0.1.0.zip
new file mode 100644
index 0000000..d073331
Binary files /dev/null and b/releases/helder-win-v0.1.0.zip differ
diff --git a/src/main/config.ts b/src/main/config.ts
index 49a115d..d435076 100644
--- a/src/main/config.ts
+++ b/src/main/config.ts
@@ -27,7 +27,7 @@ export interface TerminalTheme {
export interface HelderConfig {
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 }
files: { exclude: string[]; followGitignore: boolean }
terminal: {
@@ -54,7 +54,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = {
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 },
files: { exclude: [], followGitignore: false },
terminal: {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index b48ac6b..6da2447 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -1,5 +1,5 @@
/* 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 type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
@@ -10,6 +10,7 @@ import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays'
import type { DiffSide, FileNode, GitStatus, SymbolLookup } from './types'
import { useProject, useProjectActions } from './project'
+import { useCopyOnSelect } from './copy-on-select'
import { HL } from './highlight'
import { loadSymbols } from './symbols'
import { rlog } from './log'
@@ -17,6 +18,24 @@ import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set()
+/* 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 {
const [drag, setDrag] = useState(false)
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)))
}
return (
-
+
@@ -188,6 +207,8 @@ export function App(): React.ReactElement {
// focus agent/terminal → Editor 20% / Right 50%
// Re-applied on resize + focus change; dragging still works in between.
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')
// Which column currently has focus — drives the active-panel tint and keyboard
// 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
// splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits).
- const [autoResize, setAutoResize] = useState(true)
+ const [fluid, setFluid] = useState(true)
const [showHidden, setShowHidden] = useState(false)
// 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.
@@ -209,7 +230,7 @@ export function App(): React.ReactElement {
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
useEffect(() => {
- if (!autoResize) return
+ if (!fluid) return
function apply(): void {
const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) {
@@ -226,7 +247,29 @@ export function App(): React.ReactElement {
apply()
window.addEventListener('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.
useEffect(() => {
@@ -387,6 +430,8 @@ export function App(): React.ReactElement {
saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide })
}, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch])
+ useCopyOnSelect(proj.config.editor.copyOnSelect)
+
function toast(title: string, ref?: string): void {
const id = lid()
setToasts((t) => [...t, { id, title, ref }])
@@ -613,23 +658,6 @@ export function App(): React.ReactElement {
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 {
- 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 ----
// 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,
@@ -898,9 +926,6 @@ export function App(): React.ReactElement {
// ⌘N opens the project note.
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() === '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() }
// ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
// the current text selection to the agent (else native nav).
@@ -919,10 +944,16 @@ export function App(): React.ReactElement {
if (hasSelection()) return
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') {
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.
else if (meta && e.code === 'Period') {
@@ -1025,11 +1056,11 @@ export function App(): React.ReactElement {
Search ⌘F
- Auto-fit panels: on — columns re-fit on resize and focus. Click to lock the current sizes.>
- : <>Auto-fit panels: off — the sizes are locked. Click to re-enable.>}>
-