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

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project state ## 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`): **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. - **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`. - 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. - 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). - 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) ## 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 ## 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) ## 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. 2. Static layout: four resizable columns + title/status bars.
3. Real file tree + open files into tabs (read-only) with Prism highlighting. 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. 4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split.

20
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "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", "@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -1490,10 +1491,19 @@
} }
} }
}, },
"node_modules/@fontsource/jetbrains-mono": { "node_modules/@fontsource/ibm-plex-mono": {
"version": "5.2.8", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", "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", "license": "OFL-1.1",
"funding": { "funding": {
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"

View File

@@ -22,7 +22,8 @@
"postinstall": "electron-rebuild -f -w node-pty" "postinstall": "electron-rebuild -f -w node-pty"
}, },
"dependencies": { "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", "@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",

2
screenshots/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -61,27 +61,30 @@ export const DEFAULTS: HelderConfig = {
shell: null, shell: null,
fontFamily: null, fontFamily: null,
fontSize: null, fontSize: null,
lineHeight: 1.35, lineHeight: 1.7,
letterSpacing: 0, letterSpacing: 0,
cursorStyle: 'bar', cursorStyle: 'bar',
cursorBlink: true, cursorBlink: true,
boldIsBright: false, boldIsBright: false,
scrollback: 8000, scrollback: 8000,
optionIsMeta: false, optionIsMeta: false,
// The app's own palette: charcoal ground, the accent on the caret and the // The app's own palette: the editor ground, amber on the caret and the
// selection, and the syntax colours reused for the ANSI table so a diff in // selection, and the six muted syntax colours on the ANSI table, so a diff
// the terminal reads like a diff in the editor. // 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: { theme: {
background: '#24272c', background: '#101720',
foreground: '#dde1e7', foreground: '#E4E7E6',
cursor: '#f19f3f', cursor: '#E8913A',
cursorAccent: '#24272c', cursorAccent: '#101720',
selectionBackground: 'rgba(241,159,63,0.32)', selectionBackground: 'rgba(232,145,58,0.22)',
selectionInactiveBackground: 'rgba(241,159,63,0.18)', selectionInactiveBackground: 'rgba(232,145,58,0.12)',
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf', blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077', brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd', brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
}, },
}, },
session: { restoreOnLaunch: true }, 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). */ * theme (see the design tokens in the app's styles). */
:root { :root {
/* Code surfaces (editor + terminals) */ /* Code surfaces (editor + terminals) */
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */ /* --code-font: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
/* --code-size: 13px; */ /* editor font size */ /* --code-size: 13px; */ /* editor font size */
/* --term-size: 12.5px; */ /* terminal font size, unless config.json sets one */ /* --term-size: 12px; */ /* terminal font size, unless config.json sets one */
/* The terminal's colours, cursor and scrollback live in config.json, under /* 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 "terminal" — the palette is JS options, not CSS, because xterm paints to a
canvas. Edit either file and the running terminals restyle themselves. */ canvas. Edit either file and the running terminals restyle themselves. */
/* Example accent override: */ /* Example accent override: */
/* --accent: #4d8dff; */ /* --accent: #E8913A; */
} }
` `

View File

@@ -1,6 +1,6 @@
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */ /* App shell: 4 resizable columns, keyboard shortcuts, copy-reference */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' 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 type { ContextTarget } from './components'
import { Editor, SplitView } from './editor' import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } 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): // Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38% // ≥ 1600px (roomy) → Git 13.4% · Explorer 16.4% · Editor 43% · Right 27.3%
// (no focus-driven changes — everything fits) // (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: // < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30% // default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20% // focus editor → Editor 50% / Right 20%
@@ -212,9 +213,9 @@ export function App(): React.ReactElement {
function apply(): void { function apply(): void {
const w = window.innerWidth const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) { if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1)) setGitW(Math.round(w * 0.134))
setTreeW(Math.round(w * 0.15)) setTreeW(Math.round(w * 0.164))
setRightW(Math.round(w * 0.38)) setRightW(Math.round(w * 0.273))
} else { } else {
setGitW(Math.round(w * 0.15)) setGitW(Math.round(w * 0.15))
setTreeW(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 stagedRows = visible.filter((c) => c.staged)
const changeRows = 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). // 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]) }, [proj.changes])
const treeNav = useMemo(() => { const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = [] 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.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) }) : { 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.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). // Rename + Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') { if (isDir || target.kind === 'file') {
items.push({ sep: true }) 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.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.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 } 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 activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : [] 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. // No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) { if (proj.ready && !proj.root) {
@@ -998,43 +1003,57 @@ export function App(): React.ReactElement {
} }
return ( return (
<div className="app"> <div className={'app' + (fullscreen ? ' fullscreen' : '')}>
{/* title bar */} {/* title bar */}
<div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}> <div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div> <div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title"> <div className="tb-title">
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b> <span className="tb-word">helder<i>.</i></span>
<span style={{ color: 'var(--fg-3)' }}>{proj.branch}</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> </div>
{active && ( {active ? (
<div className="tb-crumb"> <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>} {isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div> </div>
)} ) : <div className="tb-spacer" />}
<div className="tb-spacer" />
<div className="tb-actions"> <div className="tb-actions">
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }} <Tip text={<>Search the open file, the project and the file names.</>}>
title="Search contents & names"> <button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}>
{Icon.search()} Search <kbd>F</kbd> Search <kbd>F</kbd>
</button> </button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)} </Tip>
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.'}> <Tip text={autoResize
{Icon.layout()} Auto-fit <kbd>A</kbd> ? <>Auto-fit panels: <b>on</b> columns re-fit on resize and focus. Click to lock the current sizes.</>
</button> : <>Auto-fit panels: <b>off</b> the sizes are locked. Click to re-enable.</>}>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)} <button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((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.'}> Auto-fit <kbd>A</kbd>
{Icon.eye()} Hidden <kbd>.</kbd> </button>
</button> </Tip>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')} <Tip text={showHidden
title="Project note (.notes.txt) — kept next to this project"> ? <>Hidden files: <b>shown</b> dotfiles appear in the tree and the search. Click to hide them.</>
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd> : <>Hidden files: <b>hidden</b> dotfiles are excluded from the tree and the search. Click to show them.</>}>
</button> <button className={'tb-btn tb-toggle' + (showHidden ? ' on' : ' muted')} onClick={() => setShowHidden((v) => !v)}>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)} Hidden <kbd>.</kbd>
title={showGit ? 'Source Control column: shown. Click to hide it.' : 'Source Control column: hidden. Click to show it.'}> </button>
{Icon.branch()} Git <kbd>G</kbd> </Tip>
</button> <Tip text={<>Project note (.notes.txt) kept next to this project.</>}>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button> <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>
</div> </div>
@@ -1060,7 +1079,7 @@ export function App(): React.ReactElement {
commitMsg={commitMsg} setCommitMsg={setCommitMsg} commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push} 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} 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> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> <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' }} <div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }} onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}
onMouseOver={(e) => syncTreeSel(e.target)}> onMouseOver={(e) => syncTreeSel(e.target)}>
<div className="phead">Explorer<span className="ct">{proj.name}</span></div>
{proj.tree ? ( {proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile} <FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
@@ -1098,6 +1118,24 @@ export function App(): React.ReactElement {
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />} onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
</div> </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 */} {/* overlays */}
{splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />} {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} {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>), 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 => ( 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' }}> <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" /> <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/') 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 c: Change
activePath: string | null activePath: string | null
/** Which half the open tab is showing, so only that row lights up. */ /** Which half the open tab is showing, so only that row lights up. */
activeSide: DiffSide | null activeSide: DiffSide | null
ctxPath: string | null ctxPath: string | null
kbdId: string | null kbdId: string | null
showDir: boolean
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
onToggleStage: (path: string) => void onToggleStage: (path: string) => void
@@ -91,11 +115,9 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
const staged = c.staged const staged = c.staged
const side: DiffSide = staged ? 'staged' : 'unstaged' const side: DiffSide = staged ? 'staged' : 'unstaged'
const name = c.path.split('/').pop() 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) const isActive = activePath === c.path && (!activeSide || activeSide === side)
return ( 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} data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })} onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} 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> <span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} /> <FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span> <span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dirShown && <span className="git-dir">{dir}/</span>} <button className="git-act push" title={staged ? 'Unstage changes' : 'Stage changes'}
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}> onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()} {staged ? Icon.minus() : Icon.plus()}
</button> </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 branch: string
changes: Change[] changes: Change[]
committed: Set<string> committed: Set<string>
@@ -130,12 +189,10 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
activeSide: DiffSide | null activeSide: DiffSide | null
ctxPath: string | null ctxPath: string | null
kbdId: string | null kbdId: string | null
showDir: boolean
}): React.ReactElement { }): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path)) const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => c.staged) const stagedList = visible.filter((c) => c.staged)
const changesList = 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 const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
return ( return (
@@ -150,42 +207,41 @@ export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg,
<div className="git-body"> <div className="git-body">
{visible.length === 0 ? ( {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> <Fragment>
<div className="git-group"> <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>} {stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
<span className="gc">{stagedList.length}</span>
</div> </div>
{stagedList.length > 0 ? stagedList.map((c) => ( {stagedList.length > 0 ? (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir} <GitList list={stagedList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} /> 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-divider" />
<div className="git-group"> <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>} {changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
<span className="gc">{changesList.length}</span>
</div> </div>
{changesList.length > 0 ? changesList.map((c) => ( {changesList.length > 0 ? (
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir} <GitList list={changesList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : ( ) : (
<div className="git-none">All changes staged</div> <div className="git-none"> All changes are staged.</div>
)} )}
</Fragment> </Fragment>
)} )}
</div> </div>
{/* The line counts live in the status bar; this footer only counts files. */}
<div className="git-foot"> <div className="git-foot">
<span className="branch-chip">{Icon.branch()}<b>{branch}</b></span> <span>{stagedList.length} staged · {changesList.length} unstaged</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>
</div> </div>
</Fragment> </Fragment>
) )
@@ -206,13 +262,13 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
committed: Set<string> committed: Set<string>
showHidden: boolean showHidden: boolean
}): React.ReactElement { }): React.ReactElement {
const pad = 10 + depth * 13 const pad = 12 + depth * 16
if (node.type === 'dir') { if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === '' const isOpen = openDirs.has(node.path) || node.path === ''
return ( return (
<Fragment> <Fragment>
{node.path !== '' && ( {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} data-row-path={node.path}
onClick={() => toggleDir(node.path)} onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> 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] const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return ( return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} <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} data-row-path={node.path}
onClick={() => onOpen(node.path)} onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} 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 -------------------------- // ---- file-type icon: colored monogram chip --------------------------
interface IconMeta { c: string; t: string } 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> = { const ICONS: Record<string, IconMeta> = {
php: { c: '#a78bdb', t: 'php' }, php: { c: PURPLE, t: 'php' },
js: { c: '#e6c860', t: 'js' }, blade: { c: PURPLE, t: 'php' },
mjs: { c: '#e6c860', t: 'js' }, ts: { c: PURPLE, t: 'ts' },
ts: { c: '#5a9bd6', t: 'ts' }, tsx: { c: PURPLE, t: 'tsx' },
tsx: { c: '#5a9bd6', t: 'ts' }, vue: { c: PURPLE, t: 'vue' },
jsx: { c: '#5a9bd6', t: 'jsx' }, js: { c: AMBER, t: 'js' },
py: { c: '#5fa8d6', t: 'py' }, mjs: { c: AMBER, t: 'js' },
html: { c: '#e08b6a', t: '<>' }, cjs: { c: AMBER, t: 'js' },
css: { c: '#5a9bd6', t: '{}' }, jsx: { c: AMBER, t: 'jsx' },
scss: { c: '#d6699e', t: '{}' }, json: { c: AMBER, t: 'json' },
json: { c: '#d8a85c', t: '{}' }, yml: { c: AMBER, t: 'yml' },
md: { c: '#9aa0a8', t: 'md' }, yaml: { c: AMBER, t: 'yml' },
env: { c: '#7fc6a0', t: '$' }, css: { c: TEAL, t: 'css' },
sh: { c: '#7fc6a0', t: '$' }, scss: { c: TEAL, t: 'scss' },
yml: { c: '#cf7a6a', t: 'yml' }, pcss: { c: TEAL, t: 'css' },
yaml: { c: '#cf7a6a', t: 'yml' }, html: { c: TEAL, t: 'html' },
lock: { c: '#8a8f98', t: 'lk' }, 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> = { const NAME_ICONS: Record<string, IconMeta> = {
'composer.json': { c: '#a78bdb', t: 'co' }, 'composer.json': { c: PURPLE, t: 'json' },
'package.json': { c: '#cf7a6a', t: 'pk' }, 'package.json': { c: AMBER, t: 'json' },
'README.md': { c: '#5a9bd6', t: 'md' }, '.env': { c: TEAL, t: 'env' },
'.env': { c: '#7fc6a0', t: '$' },
} }
function iconFor(path: string): IconMeta { function iconFor(path: string): IconMeta {
const base = path.split('/').pop() || '' const base = path.split('/').pop() || ''
if (NAME_ICONS[base]) return NAME_ICONS[base] 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 } 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-drag" />
<div className="launcher-card"> <div className="launcher-card">
<div className="lp-head"> <div className="lp-head">
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })} <span className="lp-mark">{Icon.spark({ width: 16, height: 16 })}</span>
<div className="lp-title"><b>Helder</b><span>Open a project to begin</span></div> <div className="lp-title"><b>helder<i>.</i></b><span>Open a project to begin</span></div>
</div> </div>
<div className="lp-list" ref={listRef}> <div className="lp-list" ref={listRef}>
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')} <div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}> onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
<span className="lp-ic">{Icon.plus()}</span> <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> <div className="lp-txt"><span className="lp-name">Open new project</span></div>
<kbd></kbd> <span className="esc-chip"></span>
</div> </div>
{recents.length > 0 && <div className="lp-sec">Recent</div>} {recents.length > 0 && <div className="lp-sec">Recent</div>}
{recents.map((p, i) => { {recents.map((p, i) => {
@@ -65,7 +65,7 @@ export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
) )
})} })}
</div> </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>
</div> </div>
) )

View File

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

View File

@@ -12,6 +12,8 @@ import type { SymbolDef, SymbolLookup } from './types'
export interface MenuItem { export interface MenuItem {
sep?: boolean sep?: boolean
primary?: boolean primary?: boolean
/** Destructive item (delete, discard). Reads in amber-deep. */
danger?: boolean
icon?: React.ReactElement icon?: React.ReactElement
label?: string label?: string
kbd?: string kbd?: string
@@ -261,10 +263,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
<FileIcon path={activePath as string} /> <FileIcon path={activePath as string} />
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span> <span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>} {inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
<kbd className="col-kbd"></kbd>
</div> </div>
{term.length < 1 && <div className="pempty sm">Type to search 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>} {term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file.</div>}
{inFile.slice(0, 200).map((h, i) => ( {inFile.slice(0, 200).map((h, i) => (
<div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')} <div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')}
onMouseEnter={() => { setCol('infile'); setInFileSel(i) }} onMouseEnter={() => { setCol('infile'); setInFileSel(i) }}
@@ -276,9 +277,9 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
</div> </div>
)} )}
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}> <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> <div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
{term.length < 2 && <div className="pempty">Type at least 2 characters</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>} {term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches.</div>}
{visibleContent.map((g) => ( {visibleContent.map((g) => (
<Fragment key={g.path}> <Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}> <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>
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}> <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> <div className="sc-head">Files <span className="sc-ct">{files.length}</span></div>
{!term && <div className="pempty sm">Start typing</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>} {term && files.length === 0 && <div className="pempty sm">No file names match.</div>}
{files.slice(0, 40).map((r, i) => { {files.slice(0, 40).map((r, i) => {
const name = r.path.split('/').pop() as string const name = r.path.split('/').pop() as string
const dir = r.path.split('/').slice(0, -1).join('/') const dir = r.path.split('/').slice(0, -1).join('/')
@@ -323,6 +324,13 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
})} })}
</div> </div>
</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>
</div> </div>
) )
@@ -458,31 +466,48 @@ export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
} }
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */ /* Keyboard-shortcuts reference (opened from the title-bar ? button). */
const SHORTCUTS: { keys: string[]; label: string }[] = [ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' }, {
{ keys: ['⌘', '↑'], label: 'Navigate a list up' }, group: 'Navigate',
{ keys: ['⌘', '↓'], label: 'Navigate a list down' }, rows: [
{ keys: [''], label: 'Open the selected list item' }, { keys: ['⌘', 'F'], label: 'Search contents and names' },
{ keys: ['⌘', ''], label: 'Search: focus the column to the left (this file · project · names)' }, { keys: ['⌘', '↑↓'], label: 'Move through a list' },
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' }, { keys: [''], label: 'Open the selected item' },
{ keys: ['', ''], label: 'Git/Explorer: move the row cursor (panel must be focused)' }, { keys: ['', '←→'], label: 'Column left or right' },
{ keys: [''], label: 'Git/Explorer: open the selected row' }, { keys: ['↑↓'], label: 'Git or Explorer: move the row cursor' },
{ keys: ['⌘', ''], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' }, { keys: ['⇧', '⌘', 'O'], label: 'Open a recent project' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'O'], label: 'Open a project folder' },
{ 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' }, group: 'Agent',
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' }, rows: [
{ keys: ['⌘', 'G'], label: 'Show or hide the Source Control column' }, { keys: ['⌘', ''], label: 'Pass the selection to the agent' },
{ keys: ['⌘', 'S'], label: 'Save the current file' }, { keys: ['⌘', 'N'], label: 'Open the project note' },
{ keys: ['⌘', 'W'], label: 'Close the current file' }, { keys: ['⌘', 'P'], label: 'Pass on the whole note' },
{ 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' }, group: 'Git',
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' }, rows: [
{ keys: ['Esc'], label: 'Close an overlay / split view' }, { 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 { 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="scrim" onMouseDown={onClose}>
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}> <div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi"> <div className="pi">
{Icon.help({ style: { color: 'var(--fg-3)' } })} {Icon.help()}
<span className="hist-title">Keyboard shortcuts</span> <span className="hist-title">Keyboard shortcuts</span>
<kbd>esc</kbd> <span className="esc-chip">esc</span>
</div> </div>
<div className="help-list"> <div className="help-list">
{SHORTCUTS.map((s, i) => ( {SHORTCUTS.map((g) => (
<div key={i} className="help-row"> <Fragment key={g.group}>
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span> <div className="help-sec">{g.group}</div>
<span className="help-label">{s.label}</span> {g.rows.map((r, i) => (
</div> <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>
</div> </div>
@@ -531,15 +561,16 @@ export function NotesModal({ text, onChange, onClose }: {
<div className="scrim" onMouseDown={onClose}> <div className="scrim" onMouseDown={onClose}>
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}> <div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi"> <div className="pi">
{Icon.note({ style: { color: 'var(--fg-3)' } })} {Icon.note()}
<span className="hist-title">Note</span> <span className="hist-title">Note</span>
<span className="notes-file">.notes.txt</span> <span className="notes-file">.notes.txt</span>
<span className="notes-hint">{Icon.spark()} To agent <kbd>P</kbd></span> <span className="notes-hint">To the agent <kbd>P</kbd></span>
<kbd>esc</kbd> <span className="esc-chip">esc</span>
</div> </div>
<textarea ref={ref} className="notes-input" spellCheck={false} <textarea ref={ref} className="notes-input" spellCheck={false}
placeholder="Anything you want to keep next to this project…" placeholder="Anything you want to keep next to this project…"
value={text} onChange={(e) => onChange(e.target.value)} /> value={text} onChange={(e) => onChange(e.target.value)} />
<div className="notes-foot">saved on focus loss · {text.length} characters</div>
</div> </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 }}> <div className="ctx-menu" ref={ref} style={{ left: x, top: y }}>
{menu.note && <div className="ctx-note">{menu.note}</div>} {menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : ( {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)} onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}> onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span> <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) const payload = buildPass(text, refStr, code)
return ( return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}> <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} <input ref={inputRef} className="pass-input" value={text} spellCheck={false}
placeholder="Add a note (optional)…" placeholder="Add a note (optional)…"
onChange={(e) => setText(e.target.value)} 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() } 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-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> </div>
) )
} }
@@ -885,7 +916,7 @@ export function NamePopup({ x, y, dir, kind = 'file', mode = 'create', initial =
else if (e.key === 'Escape') { e.preventDefault(); onCancel() } 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-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> </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 let _lid = 0
export const lid = (): number => ++_lid 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, /* Everything visual comes from `terminal` in config.json — palette, font,
* cursor, scrollback — so the console is themed from the same file as the rest * 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) const css = getComputedStyle(document.documentElement)
return { return {
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO, 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, lineHeight: cfg.lineHeight,
letterSpacing: cfg.letterSpacing, letterSpacing: cfg.letterSpacing,
cursorStyle: cfg.cursorStyle, cursorStyle: cfg.cursorStyle,

View File

@@ -132,27 +132,30 @@ export const DEFAULT_CONFIG: HelderConfig = {
shell: null, shell: null,
fontFamily: null, fontFamily: null,
fontSize: null, fontSize: null,
lineHeight: 1.35, lineHeight: 1.7,
letterSpacing: 0, letterSpacing: 0,
cursorStyle: 'bar', cursorStyle: 'bar',
cursorBlink: true, cursorBlink: true,
boldIsBright: false, boldIsBright: false,
scrollback: 8000, scrollback: 8000,
optionIsMeta: false, optionIsMeta: false,
// The app's own palette: charcoal ground, the accent on the caret and the // The app's own palette: the editor ground, amber on the caret and the
// selection, and the syntax colours reused for the ANSI table so a diff in // selection, and the six muted syntax colours on the ANSI table, so a diff
// the terminal reads like a diff in the editor. // 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: { theme: {
background: '#24272c', background: '#101720',
foreground: '#dde1e7', foreground: '#E4E7E6',
cursor: '#f19f3f', cursor: '#E8913A',
cursorAccent: '#24272c', cursorAccent: '#101720',
selectionBackground: 'rgba(241,159,63,0.32)', selectionBackground: 'rgba(232,145,58,0.22)',
selectionInactiveBackground: 'rgba(241,159,63,0.18)', selectionInactiveBackground: 'rgba(232,145,58,0.12)',
black: '#2b2e34', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
blue: '#6aa6f0', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b0b6bf', blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
brightBlack: '#7a828d', brightRed: '#ef8385', brightGreen: '#77d186', brightYellow: '#edc077', brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
brightBlue: '#86bbf5', brightMagenta: '#dba6e9', brightCyan: '#8ad6d6', brightWhite: '#fbfcfd', brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
}, },
}, },
session: { restoreOnLaunch: true }, session: { restoreOnLaunch: true },

View File

@@ -31,14 +31,15 @@ describe('HL.langFor / langLabel', () => {
describe('HL.iconFor', () => { describe('HL.iconFor', () => {
it('uses name-specific icons', () => { it('uses name-specific icons', () => {
expect(HL.iconFor('composer.json').t).toBe('co') expect(HL.iconFor('composer.json').c).toBe('#C3A6CE')
expect(HL.iconFor('package.json').t).toBe('pk') expect(HL.iconFor('package.json').c).toBe('#F0B476')
}) })
it('falls back to extension icons', () => { it('falls back to extension icons', () => {
expect(HL.iconFor('x.php').c).toBe('#a78bdb') expect(HL.iconFor('x.php').c).toBe('#C3A6CE')
}) })
it('falls back to first two letters for unknown types', () => { it('falls back to the extension for unknown types', () => {
expect(HL.iconFor('weird.zzz').t).toBe('we') expect(HL.iconFor('weird.zzz').t).toBe('zzz')
expect(HL.iconFor('weird.zzz').c).toBe('#BAC0C0')
}) })
}) })

View File

@@ -170,7 +170,7 @@ describe('project note', () => {
const c = await boot() const c = await boot()
await openNote(c) await openNote(c)
const hint = c.querySelector('.notes-modal .notes-hint') const hint = c.querySelector('.notes-modal .notes-hint')
expect(hint?.textContent).toContain('To agent') expect(hint?.textContent).toContain('To the agent')
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P') expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
}) })