From e5f739ec24e403c74f9bccffbc184691e340bdc7 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Thu, 3 Sep 2026 15:33:15 +0200 Subject: [PATCH] fixes on the UI changes --- CLAUDE.md | 16 +- package-lock.json | 20 +- package.json | 3 +- screenshots/.gitignore | 2 + src/main/config.ts | 39 +- src/renderer/src/App.tsx | 114 +++-- src/renderer/src/components.tsx | 114 +++-- src/renderer/src/highlight.ts | 57 ++- src/renderer/src/launcher.tsx | 10 +- src/renderer/src/main.tsx | 14 +- src/renderer/src/overlays.tsx | 127 +++-- src/renderer/src/styles.css | 877 +++++++++++++++++++------------- src/renderer/src/terminals.tsx | 4 +- src/renderer/src/types.ts | 31 +- test/highlight.test.ts | 11 +- test/notes.test.tsx | 2 +- 16 files changed, 898 insertions(+), 543 deletions(-) create mode 100644 screenshots/.gitignore diff --git a/CLAUDE.md b/CLAUDE.md index fa2143d..bc41b39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project state -**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating`→`php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`). +**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. IBM Plex Mono + IBM Plex Sans are bundled locally via `@fontsource/ibm-plex-mono` and `@fontsource/ibm-plex-sans`; Prism is wired with the correct `markup-templating`→`php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`). **Phase 2 (real integrations) — complete; DESIGN.md fully implemented.** The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — `⌘P` aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (`npm test`, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (`src/preload/index.ts`): - **Filesystem** — tree + in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). The tree/index/search share one source of truth: `rg --files` (honors gitignore + `files.exclude`, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable. @@ -39,7 +39,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for - Electron (latest stable), main + renderer + preload bridge with `contextIsolation: true`. - Renderer: React 18 + TypeScript + Vite (`electron-vite` scaffold). Prototype is already React, so component structure ports directly. - Syntax highlighting: Prism 1.29 (or swap to Shiki/CodeMirror 6; token→color mapping is documented in README). -- Fonts: UI = system stack; code/mono = **JetBrains Mono bundled locally** (never Google Fonts CDN in Electron). +- Fonts: UI = **IBM Plex Sans**; code/mono/labels = **IBM Plex Mono**. Both bundled locally (never Google Fonts CDN in Electron). ## Architecture rules and gotchas (these will bite if ignored) @@ -89,11 +89,19 @@ Keep warnings honest: an expected event must not log as WARN (see `killing` in ` ## Design tokens -Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a` → `--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle. +Canonical source is **`docs/design/README.md`** plus the board `docs/design/Helder IDE.dc.html` (all screens at real pixel size) and `docs/design/screenshots/`. The tokens live in the `:root` block of `src/renderer/src/styles.css`. + +Four surfaces only: `--bg-0` editor/terminal `#101720` · `--bg-2` panels/chrome `#18202B` · `--sel`/`--border` `#232C39` · `--hover` `#1E2733`. Text ramp `#F4F5F4` → `#E4E7E6` → `#BAC0C0` → `#6C7783` → `#3A424C`. One accent, amber `#E8913A` (deep `#C4741F`, soft `#F0B476`), reserved for action, state and signal — never a decorative fill. Diff is teal `#8FBFB4` added and amber-deep `#C4741F` removed: **no green, no red anywhere**. Six muted syntax colours, all weight 400. + +Radii: 2px controls · 4px panels/menus · 8px overlays only. Row heights: 24px explorer · 26px source control · 28px status bar · 30px menu item · 34px view strip · 44px title bar. Hairlines are always 1px `#232C39`; the selection marker is a 2px amber rule. + +Row states: hover fills one step and does nothing else; selected takes `--sel` plus the 2px amber rule. Focus is always the 2px amber border plus a 2px 22% ring. Motion is colour-only, 160ms; the one exception is `blink` (stepped 1.1s) on the caret, the terminal cursor and the period in the wordmark. Respect `prefers-reduced-motion`. + +The older `design_handoff_helder_workbench/` prototype is superseded by `docs/design` — read it only for behaviour that the new board does not cover. ## Suggested implementation order (from README) -1. Electron shell + frameless dark window; port tokens to CSS vars; bundle JetBrains Mono. +1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex. 2. Static layout: four resizable columns + title/status bars. 3. Real file tree + open files into tabs (read-only) with Prism highlighting. 4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split. diff --git a/package-lock.json b/package-lock.json index f0d4131..671b42b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@fontsource/jetbrains-mono": "^5.0.20", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", @@ -1490,10 +1491,19 @@ } } }, - "node_modules/@fontsource/jetbrains-mono": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", - "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" diff --git a/package.json b/package.json index 0061743..e5b1ec7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "postinstall": "electron-rebuild -f -w node-pty" }, "dependencies": { - "@fontsource/jetbrains-mono": "^5.0.20", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", diff --git a/screenshots/.gitignore b/screenshots/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/screenshots/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/src/main/config.ts b/src/main/config.ts index bbb8a86..49a115d 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -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; */ } ` diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 19690ee..b48ac6b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 ( -
+
{/* title bar */}
- actions.openFolder()}>{proj.name} - {proj.branch} + helder. + actions.openFolder()}>{proj.name} + · + {proj.branch}
- {active && ( + {active ? (
- {crumb.map((s, i) => ({i > 0 && }{s}))} + {crumb.map((s, i) => ({i > 0 && }{s}))} {isDirty(active) && }
- )} -
+ ) :
}
- - - - - - + Search the open file, the project and the file names.}> + + + Auto-fit panels: on — columns re-fit on resize and focus. Click to lock the current sizes. + : <>Auto-fit panels: off — the sizes are locked. Click to re-enable.}> + + + Hidden files: shown — dotfiles appear in the tree and the search. Click to hide them. + : <>Hidden files: hidden — dotfiles are excluded from the tree and the search. Click to show them.}> + + + Project note (.notes.txt) — kept next to this project.}> + + + Source Control column: shown. Click to hide it. + : <>Source Control column: hidden. Click to show it.}> + + + Keyboard shortcuts.}> + +
@@ -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} />
{ setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> )} @@ -1068,6 +1087,7 @@ export function App(): React.ReactElement {
{ setActivePanel('tree'); syncTreeSel(e.target) }} onMouseOver={(e) => syncTreeSel(e.target)}> +
Explorer{proj.name}
{proj.tree ? ( { setFocusZone('terminal'); setActivePanel('terminal') }} />}
+ {/* status bar — display only: nothing here is clickable */} +
+ ⑂ {proj.branch} + +{totals.add} + −{totals.del} + + {active && <> + {active} + · + Ln {cursor && cursor.path === active ? cursor.line : 1}, Col {cursor && cursor.path === active ? cursor.col : 1} + · + } + UTF-8 + · + LF + {active && <>·{HL.langLabel(active)}} +
+ {/* overlays */} {splitFor && setSplitFor(null)} onContext={openMenu} />} {passPopup && React.ReactElement> = { push: (p) => (), } +/** 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(null) + const timer = React.useRef | 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 ( + + {children} + {at &&
{text}
} +
+ ) +} + export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( @@ -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 ( -
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 {c.status} {name} - {dirShown && {dir}/} - @@ -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 ( + + {groupByDir(list).map((g) => ( + +
+ + {g.dir || '.'} +
+ {g.rows.map((c) => )} +
+ ))} +
+ ) +} + +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 @@ -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,
{visible.length === 0 ? ( -
{Icon.check({ width: 20, height: 20 })}No changes — working tree clean
+
— No changes. The working tree is clean.
) : (
- Staged Changes {stagedList.length} + Staged Changes {stagedList.length > 0 && } + {stagedList.length}
- {stagedList.length > 0 ? stagedList.map((c) => ( - 0 ? ( + - )) : ( -
Nothing staged — use + to stage a file
+ ) : ( +
— Nothing staged.
)}
- Changes {changesList.length} + Changes {changesList.length > 0 && } + {changesList.length}
- {changesList.length > 0 ? changesList.map((c) => ( - 0 ? ( + - )) : ( -
All changes staged
+ ) : ( +
— All changes are staged.
)} )}
+ {/* The line counts live in the status bar; this footer only counts files. */}
- {Icon.branch()}{branch} - - +{totals.add}{' '} - -{totals.del} - + {stagedList.length} staged · {changesList.length} unstaged
) @@ -206,13 +262,13 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP committed: Set 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 ( {node.path !== '' && ( -
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 (
onOpen(node.path)} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} diff --git a/src/renderer/src/highlight.ts b/src/renderer/src/highlight.ts index 93d5d25..d159318 100644 --- a/src/renderer/src/highlight.ts +++ b/src/renderer/src/highlight.ts @@ -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 = { - 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 = { - '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 } diff --git a/src/renderer/src/launcher.tsx b/src/renderer/src/launcher.tsx index e727146..8cd1e94 100644 --- a/src/renderer/src/launcher.tsx +++ b/src/renderer/src/launcher.tsx @@ -43,15 +43,15 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
- {Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })} -
HelderOpen a project to begin
+ {Icon.spark({ width: 16, height: 16 })} +
helder.Open a project to begin
setSel(0)} onClick={() => activate(0)}> {Icon.plus()} -
Open new project…Choose a folder
- +
Open new project…
+
{recents.length > 0 &&
Recent
} {recents.map((p, i) => { @@ -65,7 +65,7 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: { ) })}
-
⌘↓ ⌘↑ navigate · open
+
⌘↑↓ navigate ·  open
) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index c179849..cc7efb9 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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' diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 524474d..e5c28bd 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -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 {(activePath as string).split('/').pop()} {inFile.length > 0 && {inFile.length}} - ⌘←
- {term.length < 1 &&
Type to search this file
} - {term.length >= 1 && inFile.length === 0 &&
No matches in this file
} + {term.length < 1 &&
Type to search this file.
} + {term.length >= 1 && inFile.length === 0 &&
No matches in this file.
} {inFile.slice(0, 200).map((h, i) => (
{ setCol('infile'); setInFileSel(i) }} @@ -276,9 +277,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
)}
-
Project {totalHits > 0 && {totalHits}} {!hasInFile && ⌘←}
- {term.length < 2 &&
Type at least 2 characters
} - {term.length >= 2 && visibleContent.length === 0 &&
No content matches
} +
Project {totalHits > 0 && {totalHits}}
+ {term.length < 2 &&
Type at least two characters.
} + {term.length >= 2 && visibleContent.length === 0 &&
No content matches.
} {visibleContent.map((g) => (
onOpenAt(g.path, g.hits[0].no)}> @@ -302,9 +303,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet ))}
-
Files {files.length > 0 && {files.length}} ⌘→
- {!term &&
Start typing…
} - {term && files.length === 0 &&
No file names match
} +
Files {files.length}
+ {!term &&
Type to search file names.
} + {term && files.length === 0 &&
No file names match.
} {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 })}
+
+ ⌘← / ⌘→ column + ⌘↑ / ⌘↓ row + open + + esc +
) @@ -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
e.stopPropagation()}>
- {Icon.help({ style: { color: 'var(--fg-3)' } })} + {Icon.help()} Keyboard shortcuts - esc + esc
- {SHORTCUTS.map((s, i) => ( -
- {s.keys.map((k, j) => {k})} - {s.label} -
+ {SHORTCUTS.map((g) => ( + +
{g.group}
+ {g.rows.map((r, i) => ( +
+ {r.keys.map((k, j) => {k})} + {r.label} +
+ ))} +
))}
@@ -531,15 +561,16 @@ export function NotesModal({ text, onChange, onClose }: {
e.stopPropagation()}>
- {Icon.note({ style: { color: 'var(--fg-3)' } })} + {Icon.note()} Note .notes.txt - {Icon.spark()} To agent ⌘P - esc + To the agent ⌘P + esc