This commit is contained in:
@@ -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; */
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> navigate · <b>↵</b> open</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user