This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
},
|
||||
"editor": {
|
||||
"autoSave": false,
|
||||
"tabSize": 4
|
||||
"tabSize": 4,
|
||||
"wordWrap": "markdown",
|
||||
"copyOnSelect": true
|
||||
},
|
||||
"git": {
|
||||
"confirmDiscard": true,
|
||||
@@ -19,7 +21,40 @@
|
||||
"followGitignore": false
|
||||
},
|
||||
"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": {
|
||||
"restoreOnLaunch": true
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<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;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:#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>
|
||||
@@ -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="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;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:#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>
|
||||
@@ -566,7 +566,7 @@
|
||||
<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="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>
|
||||
</sc-if>
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
releases/Helder-0.1.0-arm64.dmg
Normal file
BIN
releases/Helder-0.1.0-arm64.dmg
Normal file
Binary file not shown.
BIN
releases/helder-win-v0.1.0.zip
Normal file
BIN
releases/helder-win-v0.1.0.zip
Normal file
Binary file not shown.
@@ -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: {
|
||||
|
||||
@@ -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<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 {
|
||||
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 (
|
||||
<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 style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
|
||||
<Terminal kind="agent" />
|
||||
@@ -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<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 ----
|
||||
// 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 <kbd>⌘F</kbd>
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip text={autoResize
|
||||
? <>Auto-fit panels: <b>on</b> — columns re-fit on resize and focus. Click to lock the current sizes.</>
|
||||
: <>Auto-fit panels: <b>off</b> — the sizes are locked. Click to re-enable.</>}>
|
||||
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}>
|
||||
Auto-fit <kbd>⌘A</kbd>
|
||||
<Tip text={fluid
|
||||
? <>Column widths: <b>fluid</b> — the columns re-fit on resize and focus. Click to lock them.</>
|
||||
: <>Column widths: <b>locked</b> — the columns keep their size. Click to make them fluid.</>}>
|
||||
<button className={'tb-btn tb-toggle' + (fluid ? ' on' : '')} onClick={() => setFluid((v) => !v)}>
|
||||
Fluid <kbd>⌘L</kbd>
|
||||
</button>
|
||||
</Tip>
|
||||
<Tip text={showHidden
|
||||
@@ -1072,7 +1103,7 @@ export function App(): React.ReactElement {
|
||||
{/* workbench */}
|
||||
<div className="workbench">
|
||||
{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) }}
|
||||
onMouseOver={(e) => syncGitSel(e.target)}>
|
||||
<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}
|
||||
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} />
|
||||
</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) }}
|
||||
onMouseOver={(e) => syncTreeSel(e.target)}>
|
||||
<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>
|
||||
<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') }}>
|
||||
<Editor active={active} mode={mode} side={activeSide}
|
||||
@@ -1107,10 +1138,11 @@ export function App(): React.ReactElement {
|
||||
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
||||
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
|
||||
</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)
|
||||
const max = Math.max(280, window.innerWidth - (showGit ? gitW : 0) - treeW - 280)
|
||||
return clamp(w - dx, 280, max)
|
||||
const max = Math.max(MIN_EDITOR_W, window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W
|
||||
- (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 */}
|
||||
|
||||
@@ -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
|
||||
* keyboard cursor walks the same order, so App must flatten with this too. */
|
||||
/** 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.
|
||||
* 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[] }[] {
|
||||
const out: { dir: string; rows: Change[] }[] = []
|
||||
const byDir = new Map<string, { dir: string; rows: Change[] }>()
|
||||
for (const c of list) {
|
||||
const dir = c.path.split('/').slice(0, -1).join('/')
|
||||
const last = out[out.length - 1]
|
||||
if (last && last.dir === dir) last.rows.push(c)
|
||||
else out.push({ dir, rows: [c] })
|
||||
const group = byDir.get(dir)
|
||||
if (group) group.rows.push(c)
|
||||
else { const g = { dir, rows: [c] }; byDir.set(dir, g); out.push(g) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
52
src/renderer/src/copy-on-select.ts
Normal file
52
src/renderer/src/copy-on-select.ts
Normal 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])
|
||||
}
|
||||
@@ -501,10 +501,9 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[]
|
||||
group: 'File',
|
||||
rows: [
|
||||
{ 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: ['⌘', '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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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: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
|
||||
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);}
|
||||
|
||||
/* ============ 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
|
||||
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);
|
||||
|
||||
@@ -81,6 +81,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
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 */ }
|
||||
|
||||
let disposed = false
|
||||
@@ -119,6 +128,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
bridge.pty.write(id, d)
|
||||
})
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -98,7 +98,7 @@ export interface TerminalTheme {
|
||||
/** Effective project settings (mirrors src/main/config.ts). */
|
||||
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: {
|
||||
@@ -125,7 +125,7 @@ export interface HelderConfig {
|
||||
|
||||
export const DEFAULT_CONFIG: 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: true },
|
||||
terminal: {
|
||||
|
||||
35
test/group-by-dir.test.ts
Normal file
35
test/group-by-dir.test.ts
Normal 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')] },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user