fixes on the UI changes
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-09-03 15:33:15 +02:00
parent 47581d5718
commit e5f739ec24
16 changed files with 898 additions and 543 deletions

View File

@@ -61,27 +61,30 @@ export const DEFAULTS: HelderConfig = {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.35,
lineHeight: 1.7,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: charcoal ground, the accent on the caret and the
// selection, and the syntax colours reused for the ANSI table so a diff in
// the terminal reads like a diff in the editor.
// The app's own palette: the editor ground, amber on the caret and the
// selection, and the six muted syntax colours on the ANSI table, so a diff
// in the terminal reads like a diff in the editor. Red is amber-deep and
// green is teal — the same pair the diff views use. A terminal still needs
// eight distinguishable slots, so blue and cyan take two cool tones that
// stay inside the muted register.
theme: {
background: '#24272c',
foreground: '#dde1e7',
cursor: '#f19f3f',
cursorAccent: '#24272c',
selectionBackground: 'rgba(241,159,63,0.32)',
selectionInactiveBackground: 'rgba(241,159,63,0.18)',
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf',
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077',
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd',
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 },
@@ -94,16 +97,16 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
* theme (see the design tokens in the app's styles). */
:root {
/* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12.5px; */ /* terminal font size, unless config.json sets one */
/* --code-font: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */
/* --term-size: 12px; */ /* terminal font size, unless config.json sets one */
/* The terminal's colours, cursor and scrollback live in config.json, under
"terminal" — the palette is JS options, not CSS, because xterm paints to a
canvas. Edit either file and the running terminals restyle themselves. */
/* Example accent override: */
/* --accent: #4d8dff; */
/* --accent: #E8913A; */
}
`

View File

@@ -1,6 +1,6 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FileTree, GitPanel, Icon } from './components'
import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
@@ -179,8 +179,9 @@ export function App(): React.ReactElement {
}
// Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38%
// (no focus-driven changes — everything fits)
// ≥ 1600px (roomy) → Git 13.4% · Explorer 16.4% · Editor 43% · Right 27.3%
// (the design's 236 · 288 · flex · 480 on a 1760 frame;
// no focus-driven changes — everything fits)
// < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20%
@@ -212,9 +213,9 @@ export function App(): React.ReactElement {
function apply(): void {
const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1))
setTreeW(Math.round(w * 0.15))
setRightW(Math.round(w * 0.38))
setGitW(Math.round(w * 0.134))
setTreeW(Math.round(w * 0.164))
setRightW(Math.round(w * 0.273))
} else {
setGitW(Math.round(w * 0.15))
setTreeW(Math.round(w * 0.15))
@@ -240,7 +241,9 @@ export function App(): React.ReactElement {
const stagedRows = visible.filter((c) => c.staged)
const changeRows = visible.filter((c) => !c.staged)
// Rows, not paths: one file can sit in both groups (staged, then edited again).
return [...stagedRows, ...changeRows].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
// The list renders folder by folder, so the cursor has to walk that order too.
const flat = (l: typeof visible): typeof visible => groupByDir(l).flatMap((g) => g.rows)
return [...flat(stagedRows), ...flat(changeRows)].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
}, [proj.changes])
const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = []
@@ -684,14 +687,14 @@ export function App(): React.ReactElement {
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
items.push({ danger: true, icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
// Rename + Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') {
items.push({ sep: true })
items.push({ icon: Icon.pencil({ style: { color: 'var(--ren)' } }), label: isDir ? 'Rename folder' : 'Rename file', onClick: () => setRenamePopup({ x, y, path: target.path, isDir }) })
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
items.push({ danger: true, icon: Icon.trash(), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
}
return { items, note: ref, path: target.path }
}
@@ -991,6 +994,8 @@ export function App(): React.ReactElement {
const activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : []
// Whole-project line counts, shown in the status bar next to the branch.
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
// No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) {
@@ -998,43 +1003,57 @@ export function App(): React.ReactElement {
}
return (
<div className="app">
<div className={'app' + (fullscreen ? ' fullscreen' : '')}>
{/* title bar */}
<div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title">
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
<span style={{ color: 'var(--fg-3)' }}>{proj.branch}</span>
<span className="tb-word">helder<i>.</i></span>
<span className="tb-proj" title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</span>
<span className="tb-sep">·</span>
<span className="tb-branch">{proj.branch}</span>
</div>
{active && (
{active ? (
<div className="tb-crumb">
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> </span>}<span style={i === crumb.length - 1 ? { color: 'var(--fg-1)' } : undefined}>{s}</span></React.Fragment>))}
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"></span>}<span className={i === crumb.length - 1 ? 'leaf' : undefined}>{s}</span></React.Fragment>))}
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div>
)}
<div className="tb-spacer" />
) : <div className="tb-spacer" />}
<div className="tb-actions">
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
title="Search contents & names">
{Icon.search()} Search <kbd>F</kbd>
</button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <kbd>A</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
{Icon.eye()} Hidden <kbd>.</kbd>
</button>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}
title="Project note (.notes.txt) — kept next to this project">
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)}
title={showGit ? 'Source Control column: shown. Click to hide it.' : 'Source Control column: hidden. Click to show it.'}>
{Icon.branch()} Git <kbd>G</kbd>
</button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
<Tip text={<>Search the open file, the project and the file names.</>}>
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}>
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>
</button>
</Tip>
<Tip text={showHidden
? <>Hidden files: <b>shown</b> dotfiles appear in the tree and the search. Click to hide them.</>
: <>Hidden files: <b>hidden</b> dotfiles are excluded from the tree and the search. Click to show them.</>}>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : ' muted')} onClick={() => setShowHidden((v) => !v)}>
Hidden <kbd>.</kbd>
</button>
</Tip>
<Tip text={<>Project note (.notes.txt) kept next to this project.</>}>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}>
Note <kbd>N</kbd>
</button>
</Tip>
<Tip text={showGit
? <>Source Control column: <b>shown</b>. Click to hide it.</>
: <>Source Control column: <b>hidden</b>. Click to show it.</>}>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)}>
Git <kbd>G</kbd>
</button>
</Tip>
<Tip text={<>Keyboard shortcuts.</>}>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')}>?</button>
</Tip>
</div>
</div>
@@ -1060,7 +1079,7 @@ export function App(): React.ReactElement {
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
</>)}
@@ -1068,6 +1087,7 @@ export function App(): React.ReactElement {
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + 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>
{proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
@@ -1098,6 +1118,24 @@ export function App(): React.ReactElement {
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
</div>
{/* status bar — display only: nothing here is clickable */}
<div className="statusbar">
<span className="sb-branch"> {proj.branch}</span>
<span className="sb-add">+{totals.add}</span>
<span className="sb-del">{totals.del}</span>
<span className="sb-spacer" />
{active && <>
<span className="sb-path">{active}</span>
<span>·</span>
<span>Ln {cursor && cursor.path === active ? cursor.line : 1}, Col {cursor && cursor.path === active ? cursor.col : 1}</span>
<span>·</span>
</>}
<span>UTF-8</span>
<span>·</span>
<span>LF</span>
{active && <><span>·</span><span className="sb-lang">{HL.langLabel(active)}</span></>}
</div>
{/* overlays */}
{splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}

View File

@@ -32,6 +32,31 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
}
/** Hover tooltip. One surface, no arrow, no shadow — the current state is named
* inside the sentence, in amber, then one sentence saying what a click does. */
export function Tip({ text, children }: { text: React.ReactNode; children: React.ReactNode }): React.ReactElement {
const [at, setAt] = React.useState<{ x: number; y: number } | null>(null)
const ref = React.useRef<HTMLSpanElement>(null)
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
function enter(): void {
timer.current = setTimeout(() => {
const r = ref.current?.getBoundingClientRect()
if (r) setAt({ x: Math.min(r.left, window.innerWidth - 316), y: r.bottom + 8 })
}, 350)
}
function leave(): void {
if (timer.current) clearTimeout(timer.current)
setAt(null)
}
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
return (
<span className="tip-host" ref={ref} onMouseEnter={enter} onMouseLeave={leave} onMouseDown={leave}>
{children}
{at && <div className="tip" style={{ left: at.x, top: at.y }}>{text}</div>}
</span>
)
}
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
<svg width="9" height="9" viewBox="0 0 10 10" style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .12s' }}>
<path d="M3.5 2l3.5 3-3.5 3" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
@@ -76,14 +101,13 @@ function isTestFile(path: string): boolean {
return path.startsWith('tests/') || path.startsWith('cypress/')
}
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext, onToggleStage }: {
c: Change
activePath: string | null
/** Which half the open tab is showing, so only that row lights up. */
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
@@ -91,11 +115,9 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
const staged = c.staged
const side: DiffSide = staged ? 'staged' : 'unstaged'
const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
const isActive = activePath === c.path && (!activeSide || activeSide === side)
return (
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
<div className={'git-row' + (staged ? ' staged' : '') + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
@@ -103,8 +125,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
<span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dirShown && <span className="git-dir">{dir}/</span>}
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
<button className="git-act push" title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()}
</button>
@@ -112,7 +133,45 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
)
}
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: {
/** 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. */
export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] {
const out: { 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] })
}
return out
}
function GitList({ list, ...row }: {
list: Change[]
activePath: string | null
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
onOpen: OpenFile
onContext: OnContext
onToggleStage: (path: string) => void
}): React.ReactElement {
return (
<Fragment>
{groupByDir(list).map((g) => (
<Fragment key={g.dir}>
<div className="git-dir-head" title={g.dir || '(project root)'}>
<FolderIcon open={false} />
<span className="gdh-path">{g.dir || '.'}</span>
</div>
{g.rows.map((c) => <GitRow key={c.id} c={c} {...row} />)}
</Fragment>
))}
</Fragment>
)
}
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
branch: string
changes: Change[]
committed: Set<string>
@@ -130,12 +189,10 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
}): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => c.staged)
const changesList = visible.filter((c) => !c.staged)
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
return (
@@ -150,42 +207,41 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
<div className="git-body">
{visible.length === 0 ? (
<div className="git-empty">{Icon.check({ width: 20, height: 20 })}<span>No changes working tree clean</span></div>
<div className="git-empty"> No changes. The working tree is clean.</div>
) : (
<Fragment>
<div className="git-group">
Staged Changes <span className="gc">{stagedList.length}</span>
Staged Changes
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
<span className="gc">{stagedList.length}</span>
</div>
{stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
{stagedList.length > 0 ? (
<GitList list={stagedList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
) : (
<div className="git-none"> Nothing staged.</div>
)}
<div className="git-divider" />
<div className="git-group">
Changes <span className="gc">{changesList.length}</span>
Changes
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
<span className="gc">{changesList.length}</span>
</div>
{changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
{changesList.length > 0 ? (
<GitList list={changesList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : (
<div className="git-none">All changes staged</div>
) : (
<div className="git-none"> All changes are staged.</div>
)}
</Fragment>
)}
</div>
{/* The line counts live in the status bar; this footer only counts files. */}
<div className="git-foot">
<span className="branch-chip">{Icon.branch()}<b>{branch}</b></span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--mono)' }}>
<span className="a" style={{ color: 'var(--add)' }}>+{totals.add}</span>{' '}
<span className="d" style={{ color: 'var(--del)' }}>-{totals.del}</span>
</span>
<span>{stagedList.length} staged · {changesList.length} unstaged</span>
</div>
</Fragment>
)
@@ -206,13 +262,13 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
committed: Set<string>
showHidden: boolean
}): React.ReactElement {
const pad = 10 + depth * 13
const pad = 12 + depth * 16
if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === ''
return (
<Fragment>
{node.path !== '' && (
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
<div className={'tree-row folder' + (isOpen ? ' open' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
@@ -234,7 +290,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
style={{ paddingLeft: pad + 2 }}
style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}

View File

@@ -106,37 +106,50 @@ function hlText(text: string, lang: string | null): string {
// ---- file-type icon: colored monogram chip --------------------------
interface IconMeta { c: string; t: string }
// Four fills only, taken from the syntax palette: purple, amber-soft, teal and
// the neutral grey. The text is the extension, in ink on the fill.
const PURPLE = '#C3A6CE'
const AMBER = '#F0B476'
const TEAL = '#8FBFB4'
const GREY = '#BAC0C0'
const ICONS: Record<string, IconMeta> = {
php: { c: '#a78bdb', t: 'php' },
js: { c: '#e6c860', t: 'js' },
mjs: { c: '#e6c860', t: 'js' },
ts: { c: '#5a9bd6', t: 'ts' },
tsx: { c: '#5a9bd6', t: 'ts' },
jsx: { c: '#5a9bd6', t: 'jsx' },
py: { c: '#5fa8d6', t: 'py' },
html: { c: '#e08b6a', t: '<>' },
css: { c: '#5a9bd6', t: '{}' },
scss: { c: '#d6699e', t: '{}' },
json: { c: '#d8a85c', t: '{}' },
md: { c: '#9aa0a8', t: 'md' },
env: { c: '#7fc6a0', t: '$' },
sh: { c: '#7fc6a0', t: '$' },
yml: { c: '#cf7a6a', t: 'yml' },
yaml: { c: '#cf7a6a', t: 'yml' },
lock: { c: '#8a8f98', t: 'lk' },
php: { c: PURPLE, t: 'php' },
blade: { c: PURPLE, t: 'php' },
ts: { c: PURPLE, t: 'ts' },
tsx: { c: PURPLE, t: 'tsx' },
vue: { c: PURPLE, t: 'vue' },
js: { c: AMBER, t: 'js' },
mjs: { c: AMBER, t: 'js' },
cjs: { c: AMBER, t: 'js' },
jsx: { c: AMBER, t: 'jsx' },
json: { c: AMBER, t: 'json' },
yml: { c: AMBER, t: 'yml' },
yaml: { c: AMBER, t: 'yml' },
css: { c: TEAL, t: 'css' },
scss: { c: TEAL, t: 'scss' },
pcss: { c: TEAL, t: 'css' },
html: { c: TEAL, t: 'html' },
sh: { c: TEAL, t: 'sh' },
env: { c: TEAL, t: 'env' },
py: { c: TEAL, t: 'py' },
sql: { c: TEAL, t: 'sql' },
md: { c: GREY, t: 'md' },
txt: { c: GREY, t: 'txt' },
lock: { c: GREY, t: 'lock' },
svg: { c: GREY, t: 'svg' },
}
const NAME_ICONS: Record<string, IconMeta> = {
'composer.json': { c: '#a78bdb', t: 'co' },
'package.json': { c: '#cf7a6a', t: 'pk' },
'README.md': { c: '#5a9bd6', t: 'md' },
'.env': { c: '#7fc6a0', t: '$' },
'composer.json': { c: PURPLE, t: 'json' },
'package.json': { c: AMBER, t: 'json' },
'.env': { c: TEAL, t: 'env' },
}
function iconFor(path: string): IconMeta {
const base = path.split('/').pop() || ''
if (NAME_ICONS[base]) return NAME_ICONS[base]
return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' }
return ICONS[ext(path)] || { c: GREY, t: ext(path).slice(0, 4) || '·' }
}
export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }

View File

@@ -43,15 +43,15 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
<div className="launcher-drag" />
<div className="launcher-card">
<div className="lp-head">
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })}
<div className="lp-title"><b>Helder</b><span>Open a project to begin</span></div>
<span className="lp-mark">{Icon.spark({ width: 16, height: 16 })}</span>
<div className="lp-title"><b>helder<i>.</i></b><span>Open a project to begin</span></div>
</div>
<div className="lp-list" ref={listRef}>
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
<span className="lp-ic">{Icon.plus()}</span>
<div className="lp-txt"><span className="lp-name">Open new project</span><span className="lp-path">Choose a folder</span></div>
<kbd></kbd>
<div className="lp-txt"><span className="lp-name">Open new project</span></div>
<span className="esc-chip"></span>
</div>
{recents.length > 0 && <div className="lp-sec">Recent</div>}
{recents.map((p, i) => {
@@ -65,7 +65,7 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
)
})}
</div>
<div className="lp-foot"><kbd></kbd> <kbd></kbd> navigate · <kbd></kbd> open</div>
<div className="lp-foot"><b></b>&nbsp;navigate · <b></b>&nbsp;open</div>
</div>
</div>
)

View File

@@ -2,10 +2,16 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
// Bundled locally (no Google Fonts CDN in Electron). Weights used by the UI.
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/600.css'
import '@fontsource/jetbrains-mono/700.css'
import '@fontsource/ibm-plex-mono/400.css'
import '@fontsource/ibm-plex-mono/500.css'
import '@fontsource/ibm-plex-mono/600.css'
import '@fontsource/ibm-plex-mono/700.css'
import '@fontsource/ibm-plex-mono/400-italic.css'
import '@fontsource/ibm-plex-sans/400.css'
import '@fontsource/ibm-plex-sans/500.css'
import '@fontsource/ibm-plex-sans/600.css'
import '@fontsource/ibm-plex-sans/700.css'
import '@fontsource/ibm-plex-sans/400-italic.css'
// Initialises Prism + all grammars (correct php load order) as a side effect.
import './highlight'

View File

@@ -12,6 +12,8 @@ import type { SymbolDef, SymbolLookup } from './types'
export interface MenuItem {
sep?: boolean
primary?: boolean
/** Destructive item (delete, discard). Reads in amber-deep. */
danger?: boolean
icon?: React.ReactElement
label?: string
kbd?: string
@@ -261,10 +263,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
<FileIcon path={activePath as string} />
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
<kbd className="col-kbd"></kbd>
</div>
{term.length < 1 && <div className="pempty sm">Type to search this file</div>}
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file</div>}
{term.length < 1 && <div className="pempty sm">Type to search this file.</div>}
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file.</div>}
{inFile.slice(0, 200).map((h, i) => (
<div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')}
onMouseEnter={() => { setCol('infile'); setInFileSel(i) }}
@@ -276,9 +277,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
</div>
)}
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} {!hasInFile && <kbd className="col-kbd"></kbd>}</div>
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
{term.length < 2 && <div className="pempty">Type at least two characters.</div>}
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches.</div>}
{visibleContent.map((g) => (
<Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
@@ -302,9 +303,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
))}
</div>
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>} <kbd className="col-kbd"></kbd></div>
{!term && <div className="pempty sm">Start typing</div>}
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
<div className="sc-head">Files <span className="sc-ct">{files.length}</span></div>
{!term && <div className="pempty sm">Type to search file names.</div>}
{term && files.length === 0 && <div className="pempty sm">No file names match.</div>}
{files.slice(0, 40).map((r, i) => {
const name = r.path.split('/').pop() as string
const dir = r.path.split('/').slice(0, -1).join('/')
@@ -323,6 +324,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
})}
</div>
</div>
<div className="ov-foot">
<span><b></b> / <b></b> column</span>
<span><b></b> / <b></b> row</span>
<span><b></b> open</span>
<span className="ov-foot-spacer" />
<span className="esc-chip">esc</span>
</div>
</div>
</div>
)
@@ -458,31 +466,48 @@ export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
}
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */
const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
{ keys: ['⌘', '↑'], label: 'Navigate a list up' },
{ keys: ['⌘', '↓'], label: 'Navigate a list down' },
{ keys: [''], label: 'Open the selected list item' },
{ keys: ['⌘', ''], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['', ''], label: 'Git/Explorer: move the row cursor (panel must be focused)' },
{ keys: [''], label: 'Git/Explorer: open the selected row' },
{ keys: ['⌘', ''], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'G'], label: 'Show or hide the Source Control column' },
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' },
const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[] = [
{
group: 'Navigate',
rows: [
{ keys: ['⌘', 'F'], label: 'Search contents and names' },
{ keys: ['⌘', '↑↓'], label: 'Move through a list' },
{ keys: [''], label: 'Open the selected item' },
{ keys: ['', '←→'], label: 'Column left or right' },
{ keys: ['↑↓'], label: 'Git or Explorer: move the row cursor' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
],
},
{
group: 'Agent',
rows: [
{ keys: ['⌘', ''], label: 'Pass the selection to the agent' },
{ keys: ['⌘', 'N'], label: 'Open the project note' },
{ keys: ['⌘', 'P'], label: 'Pass on the whole note' },
],
},
{
group: 'Git',
rows: [
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff · Split' },
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
],
},
{
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: ['Esc'], label: 'Close an overlay or a split view' },
],
},
]
export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElement {
@@ -490,16 +515,21 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
<div className="scrim" onMouseDown={onClose}>
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.help({ style: { color: 'var(--fg-3)' } })}
{Icon.help()}
<span className="hist-title">Keyboard shortcuts</span>
<kbd>esc</kbd>
<span className="esc-chip">esc</span>
</div>
<div className="help-list">
{SHORTCUTS.map((s, i) => (
<div key={i} className="help-row">
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
<span className="help-label">{s.label}</span>
</div>
{SHORTCUTS.map((g) => (
<Fragment key={g.group}>
<div className="help-sec">{g.group}</div>
{g.rows.map((r, i) => (
<div key={i} className="help-row">
<span className="help-keys">{r.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
<span className="help-label">{r.label}</span>
</div>
))}
</Fragment>
))}
</div>
</div>
@@ -531,15 +561,16 @@ export function NotesModal({ text, onChange, onClose }: {
<div className="scrim" onMouseDown={onClose}>
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.note({ style: { color: 'var(--fg-3)' } })}
{Icon.note()}
<span className="hist-title">Note</span>
<span className="notes-file">.notes.txt</span>
<span className="notes-hint">{Icon.spark()} To agent <kbd>P</kbd></span>
<kbd>esc</kbd>
<span className="notes-hint">To the agent <kbd>P</kbd></span>
<span className="esc-chip">esc</span>
</div>
<textarea ref={ref} className="notes-input" spellCheck={false}
placeholder="Anything you want to keep next to this project…"
value={text} onChange={(e) => onChange(e.target.value)} />
<div className="notes-foot">saved on focus loss · {text.length} characters</div>
</div>
</div>
)
@@ -625,7 +656,7 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
<div className="ctx-menu" ref={ref} style={{ left: x, top: y }}>
{menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (i === hi ? ' hi' : '')}
onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span>
@@ -816,7 +847,7 @@ export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
const payload = buildPass(text, refStr, code)
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
<div className="pass-head">{Icon.spark()}<span>Pass on to agent</span><span className="pass-esc">esc</span></div>
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
placeholder="Add a note (optional)…"
onChange={(e) => setText(e.target.value)}
@@ -825,7 +856,7 @@ export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
<div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div>
<div className="pass-foot"><b></b> insert into agent · <b>esc</b> cancel</div>
</div>
)
}
@@ -885,7 +916,7 @@ export function NamePopup({ x, y, dir, kind = 'file', mode = 'create', initial =
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">{isRename ? 'renames to' : 'creates'}</span><code>{trimmed ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> {isRename ? 'rename' : 'create'} {noun} · <kbd>esc</kbd> cancel</div>
<div className="pass-foot"><b></b> {isRename ? 'rename' : 'create'} {noun} · <b>esc</b> cancel</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ import type { HelderConfig } from './types'
let _lid = 0
export const lid = (): number => ++_lid
const MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
/* Everything visual comes from `terminal` in config.json — palette, font,
* cursor, scrollback — so the console is themed from the same file as the rest
@@ -33,7 +33,7 @@ function xtermOptions(cfg: HelderConfig['terminal']): {
const css = getComputedStyle(document.documentElement)
return {
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO,
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12.5,
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12,
lineHeight: cfg.lineHeight,
letterSpacing: cfg.letterSpacing,
cursorStyle: cfg.cursorStyle,

View File

@@ -132,27 +132,30 @@ export const DEFAULT_CONFIG: HelderConfig = {
shell: null,
fontFamily: null,
fontSize: null,
lineHeight: 1.35,
lineHeight: 1.7,
letterSpacing: 0,
cursorStyle: 'bar',
cursorBlink: true,
boldIsBright: false,
scrollback: 8000,
optionIsMeta: false,
// The app's own palette: charcoal ground, the accent on the caret and the
// selection, and the syntax colours reused for the ANSI table so a diff in
// the terminal reads like a diff in the editor.
// The app's own palette: the editor ground, amber on the caret and the
// selection, and the six muted syntax colours on the ANSI table, so a diff
// in the terminal reads like a diff in the editor. Red is amber-deep and
// green is teal — the same pair the diff views use. A terminal still needs
// eight distinguishable slots, so blue and cyan take two cool tones that
// stay inside the muted register.
theme: {
background: '#24272c',
foreground: '#dde1e7',
cursor: '#f19f3f',
cursorAccent: '#24272c',
selectionBackground: 'rgba(241,159,63,0.32)',
selectionInactiveBackground: 'rgba(241,159,63,0.18)',
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf',
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077',
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd',
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 },