This commit is contained in:
16
CLAUDE.md
16
CLAUDE.md
@@ -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
20
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
@@ -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
2
screenshots/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -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; */
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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' })}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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> navigate · <b>↵</b> open</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user