Compare commits
8 Commits
e5f739ec24
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d1c5df83 | |||
| f6a551d7c4 | |||
| 1ebb9a0e74 | |||
| 008adbfdb6 | |||
| bb012702fe | |||
| 2987794a9b | |||
| 3746374d94 | |||
| bccc97c11c |
@@ -5,13 +5,14 @@
|
|||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"autoSave": false,
|
"autoSave": false,
|
||||||
"tabSize": 4
|
"tabSize": 4,
|
||||||
|
"wordWrap": "markdown"
|
||||||
},
|
},
|
||||||
"git": {
|
"git": {
|
||||||
"confirmDiscard": true,
|
"confirmDiscard": true,
|
||||||
"confirmStage": false,
|
"confirmStage": false,
|
||||||
"confirmUnstage": false,
|
"confirmUnstage": false,
|
||||||
"defaultDiffMode": "diff",
|
"defaultDiffMode": "updated",
|
||||||
"refreshInterval": 10000
|
"refreshInterval": 10000
|
||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
@@ -19,7 +20,40 @@
|
|||||||
"followGitignore": false
|
"followGitignore": false
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"shell": null
|
"shell": null,
|
||||||
|
"fontFamily": null,
|
||||||
|
"fontSize": null,
|
||||||
|
"lineHeight": 1.7,
|
||||||
|
"letterSpacing": 0,
|
||||||
|
"cursorStyle": "bar",
|
||||||
|
"cursorBlink": true,
|
||||||
|
"boldIsBright": false,
|
||||||
|
"scrollback": 8000,
|
||||||
|
"optionIsMeta": false,
|
||||||
|
"theme": {
|
||||||
|
"background": "#101720",
|
||||||
|
"foreground": "#E4E7E6",
|
||||||
|
"cursor": "#E8913A",
|
||||||
|
"cursorAccent": "#101720",
|
||||||
|
"selectionBackground": "rgba(232,145,58,0.22)",
|
||||||
|
"selectionInactiveBackground": "rgba(232,145,58,0.12)",
|
||||||
|
"black": "#232C39",
|
||||||
|
"red": "#C4741F",
|
||||||
|
"green": "#8FBFB4",
|
||||||
|
"yellow": "#F0B476",
|
||||||
|
"blue": "#8FA9C4",
|
||||||
|
"magenta": "#C3A6CE",
|
||||||
|
"cyan": "#8FC4C4",
|
||||||
|
"white": "#BAC0C0",
|
||||||
|
"brightBlack": "#6C7783",
|
||||||
|
"brightRed": "#E8913A",
|
||||||
|
"brightGreen": "#A6D2C7",
|
||||||
|
"brightYellow": "#F5C79A",
|
||||||
|
"brightBlue": "#A9BFD6",
|
||||||
|
"brightMagenta": "#D6BFDF",
|
||||||
|
"brightCyan": "#A9D6D6",
|
||||||
|
"brightWhite": "#F4F5F4"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
"restoreOnLaunch": true
|
"restoreOnLaunch": true
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -4,11 +4,11 @@ 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. 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 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, the view modes, 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.
|
||||||
- **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
|
- **Git** — `simple-git`: status→A/M/D/R, the diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
|
||||||
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
|
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
|
||||||
|
|
||||||
- **Config** — `.helder/` per project (`src/main/config.ts`): `config.default.json` regenerated on launch (full defaults / live docs), sparse `config.json` deep-merged over it, and `theme.css` (created once, never overwritten) injected over the built-in dark theme. The **code font + size are CSS vars** (`--code-font`/`--code-size`/`--term-size`) the editor + xterm read, overridable from `theme.css`. ai command/autoLaunch + shell flow from config into the PTYs; a `.helder` file watcher hot-reloads config/theme.
|
- **Config** — `.helder/` per project (`src/main/config.ts`): `config.default.json` regenerated on launch (full defaults / live docs), sparse `config.json` deep-merged over it, and `theme.css` (created once, never overwritten) injected over the built-in dark theme. The **code font + size are CSS vars** (`--code-font`/`--code-size`/`--term-size`) the editor + xterm read, overridable from `theme.css`. ai command/autoLaunch + shell flow from config into the PTYs; a `.helder` file watcher hot-reloads config/theme.
|
||||||
@@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
The renderer consumes FS/git/config/search via the store in `src/renderer/src/project.tsx` (`useProject` / `useProjectActions`), which falls back to the mock + default config when `window.helder` is absent (browser preview). `src/main/index.ts` registers all IPC handlers + the debounced watchers.
|
The renderer consumes FS/git/config/search via the store in `src/renderer/src/project.tsx` (`useProject` / `useProjectActions`), which falls back to the mock + default config when `window.helder` is absent (browser preview). `src/main/index.ts` registers all IPC handlers + the debounced watchers.
|
||||||
|
|
||||||
- **Editing** — the `code`/`updated` modes are a writable buffer: a transparent textarea over a Prism-highlighted `<pre>` with a scroll-synced gutter (`CodeEditor` in `editor.tsx`). `⌘S` saves to disk (`fs:write`), `editor.autoSave` debounce-saves on change, tabs show the dirty dot, and the git-row context menu has **Discard changes** gated by `git.confirmDiscard`. Original/Diff/Split stay read-only review views.
|
- **Editing** — the `code`/`updated` modes are a writable buffer: a transparent textarea over a Prism-highlighted `<pre>` with a scroll-synced gutter (`CodeEditor` in `editor.tsx`). `⌘S` saves to disk (`fs:write`), `editor.autoSave` debounce-saves on change, tabs show the dirty dot, and the git-row context menu has **Discard changes** gated by `git.confirmDiscard`. Original stays a read-only review view; Actual marks the changed lines in place, and Diff is the full-screen side-by-side view.
|
||||||
|
|
||||||
README steps 1–7 plus config + editing are all implemented for real. The editable overlay keeps the caret in view (the textarea is overflow-hidden under the scroller, so `CodeEditor` scrolls the container on input/keyup/click).
|
README steps 1–7 plus config + editing are all implemented for real. The editable overlay keeps the caret in view (the textarea is overflow-hidden under the scroller, so `CodeEditor` scrolls the container on input/keyup/click).
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
|
|||||||
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
||||||
- **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync.
|
- **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync.
|
||||||
- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here).
|
- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here).
|
||||||
- **Word wrap swaps the buffer's layout, it is not just a CSS switch** (`editor.wordWrap`: `markdown` default / `on` / `off`). Unwrapped, `CodeEditor` renders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a `.ce-line` block and the number is a CSS counter on `::before` — that keeps it on the first visual row and leaves folded rows blank. Two things bite here: `.ce-inner` must drop `width:max-content` (and `.editor` its `max-content` grid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the `<pre>` must fold identically — same width, padding, font, `white-space:pre-wrap`, `overflow-wrap:break-word` — or the caret drifts off the text. Split view stays unwrapped on purpose: its panes align row by row.
|
- **Word wrap swaps the buffer's layout, it is not just a CSS switch** (`editor.wordWrap`: `markdown` default / `on` / `off`). Unwrapped, `CodeEditor` renders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a `.ce-line` block and the number is a CSS counter on `::before` — that keeps it on the first visual row and leaves folded rows blank. Two things bite here: `.ce-inner` must drop `width:max-content` (and `.editor` its `max-content` grid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the `<pre>` must fold identically — same width, padding, font, `white-space:pre-wrap`, `overflow-wrap:break-word` — or the caret drifts off the text. The full-screen Diff never wraps on purpose: its two panes align row by row. The original panel must fold at the same points as the editor, or the old line does not stay level with the current line.
|
||||||
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
|
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
|
||||||
- **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes.
|
- **The three view modes (Actual / Original / Diff), plus Preview for markdown, all derive from one original-text + updated-text pair per changed file.** The prototype computes the pair with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output. **Actual is the writable buffer, and it marks the changed lines in place.** A marked row takes the teal `--add` tint, a 2px `--add` left rule, and a teal line number. There is no `+` glyph, no sign column, and no second row. The removed lines appear on a click: a click on a marked line opens a 496px panel with a 2px `--del` left border over the agent + terminal column. The panel holds the whole original file and scrolls so the previous version of the picked line sits level with the picked line. A second click on the same line, or a click anywhere off the code, closes it. Hover does nothing, and there is no animation. A pure deletion has no current line to mark, so the neighbouring current line takes a 2px `--del` rule on its edge and opens the same panel. **Diff is the full-screen side-by-side view**: it covers the whole application, original left, updated right, lines aligned, and `Esc` returns to the previous mode. The old separate **Split** button is gone, and the `Diff` segment opens that view instead. `git.defaultDiffMode` defaults to `'updated'` (Actual), so a click on a changed git row opens the file in Actual. The git context menu's **Open diff** is the explicit way into the full-screen view. The colour language is token-based everywhere: **teal `--add` is what the file holds now, amber-deep `--del` is what it held before** — never green, never red. Syntax highlighting stays on in all modes. The design source for the marked lines and the original panel is `design_handoff_helder_inline_diff/` (README.md + `03b-in-pane-diff.html`).
|
||||||
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
|
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
|
||||||
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
|
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
|
||||||
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts` → `rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
|
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts` → `rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
|
||||||
@@ -104,7 +104,7 @@ The older `design_handoff_helder_workbench/` prototype is superseded by `docs/de
|
|||||||
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
|
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 three view modes, the original panel, and the full-screen Diff.
|
||||||
5. Search (ripgrep + fuzzy).
|
5. Search (ripgrep + fuzzy).
|
||||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
||||||
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
||||||
|
|||||||
35
DESIGN.md
35
DESIGN.md
@@ -64,19 +64,20 @@ Two stacked sections, each with its own header and count:
|
|||||||
|
|
||||||
Each row, identical in both sections:
|
Each row, identical in both sections:
|
||||||
|
|
||||||
- A status letter on the far left: `A` added, `M` modified, `D` deleted, each in its own color (added green, modified amber, deleted red).
|
- A status letter on the far left: `A` added, `M` modified, `D` deleted, each in its own color (added teal, modified amber, deleted amber-deep).
|
||||||
- A file-type icon, then the file name.
|
- A file-type icon, then the file name.
|
||||||
- The dimmed relative path, aligned to the right of the name.
|
- The dimmed relative path, aligned to the right of the name.
|
||||||
- Change counts at the far right: additions in green (`+N`) and deletions in red (`-N`).
|
- Change counts at the far right: additions in teal (`+N`) and deletions in amber-deep (`-N`).
|
||||||
- Deleted files are shown with the file name struck through and dimmed.
|
- Deleted files are shown with the file name struck through and dimmed.
|
||||||
- Rows have default, hover, and selected states. The selected row is the file currently open in the editor.
|
- Rows have default, hover, and selected states. The selected row is the file currently open in the editor.
|
||||||
|
|
||||||
### Interactions
|
### Interactions
|
||||||
|
|
||||||
- **Left-click** a row opens that file in the editor.
|
- **Left-click** a row opens that file in the editor, in **Actual**. It does not open the full-screen Diff.
|
||||||
- **Right-click** a row opens a context menu, designed to grow over time:
|
- **Right-click** a row opens a context menu, designed to grow over time:
|
||||||
- In CHANGES (unstaged): **Stage file**, and **Discard changes**.
|
- In CHANGES (unstaged): **Open diff**, **Stage file**, and **Discard changes**.
|
||||||
- In STAGED: **Unstage file**.
|
- In STAGED: **Open diff**, and **Unstage file**.
|
||||||
|
- **Open diff** is the explicit way into the full-screen Diff (section 5).
|
||||||
- **Hover quick action**: on hover, an unstaged row shows a `+` to stage it in one click, and a staged row shows a `-` to unstage it.
|
- **Hover quick action**: on hover, an unstaged row shows a `+` to stage it in one click, and a staged row shows a `-` to unstage it.
|
||||||
- Optional section actions: **Stage all** on the CHANGES header and **Unstage all** on the STAGED header.
|
- Optional section actions: **Stage all** on the CHANGES header and **Unstage all** on the STAGED header.
|
||||||
- **Discard** is a destructive action. It asks for confirmation first (controlled by `git.confirmDiscard`, default on). Discarding reverts a modified file to its committed state and restores a deleted file.
|
- **Discard** is a destructive action. It asks for confirmation first (controlled by `git.confirmDiscard`, default on). Discarding reverts a modified file to its committed state and restores a deleted file.
|
||||||
@@ -124,24 +125,26 @@ The third and widest column. Tabs on top, a view toolbar under them, and the cod
|
|||||||
A row under the tabs:
|
A row under the tabs:
|
||||||
|
|
||||||
- On the left, a status summary for the active file, for example `Modified +6 -2`.
|
- On the left, a status summary for the active file, for example `Modified +6 -2`.
|
||||||
- On the right, a four-segment control: **Original | Updated | Diff | Split**. The active segment is highlighted. This control appears only for files that have changes relative to their committed state. Its starting mode follows `git.defaultDiffMode` (default `Diff`).
|
- On the right, a segmented control: **Actual | Original | Diff**, with a fourth segment, **Preview**, on markdown files only. The active segment is highlighted. This control appears only for files that have changes relative to their committed state. Its starting mode follows `git.defaultDiffMode` (default `Actual`).
|
||||||
|
|
||||||
### View modes
|
### View modes
|
||||||
|
|
||||||
All four are presentations of the same change set for the file:
|
All are presentations of the same change set for the file:
|
||||||
|
|
||||||
1. **Original**: the file as it was before the change, read-only. Changed and removed lines get a red bar in the left gutter. No inline plus or minus markers.
|
1. **Actual**: the current, editable version of the file. It marks the changed lines in place, with a teal tint, a 2px teal left rule, and a teal line number. There is no sign column, no `+` or `-` pair, and no removed line inline. A pure deletion has no current line to mark, so the neighbouring current line carries a 2px amber-deep rule on its top edge, or on its bottom edge at the end of the file.
|
||||||
2. **Updated**: the current, editable version of the file. Added and changed lines get a green bar in the left gutter.
|
2. **Original**: the file as it was before the change, read-only. Changed and removed lines get an amber-deep bar in the left gutter. No inline plus or minus markers.
|
||||||
3. **Diff**: a single pane, unified inline diff. Removed lines are red with a `-`, added lines are green with a `+`, shown in sequence.
|
3. **Diff**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
|
||||||
4. **Split**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. Removals are marked red on the left, additions green on the right. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
|
4. **Preview**: markdown files only. It shows the rendered document instead of the source.
|
||||||
|
|
||||||
Shared rules: red always means removed or changed-from, green always means added or changed-to. Syntax highlighting stays on in all four modes. Line numbers follow `editor.lineNumbers` (default absolute).
|
**In Actual, the removed lines appear on a click.** The user clicks a marked line, and a panel opens over the agent and terminal column: 496px wide, with a 2px amber-deep left border. The panel holds the whole original file, and it scrolls so that the previous version of the picked line sits level with the picked line. A second click on the same line closes the panel, and so does a click anywhere off the code. Hover does nothing, and there is no animation.
|
||||||
|
|
||||||
|
Shared rules: teal is what the file holds now, amber-deep is what it held before. There is no green and no red anywhere. Syntax highlighting stays on in all modes. Line numbers follow `editor.lineNumbers` (default absolute).
|
||||||
|
|
||||||
### Editing behavior
|
### Editing behavior
|
||||||
|
|
||||||
- A file with no changes opens directly in a normal editable view with no view-mode control, since there is nothing to diff.
|
- A file with no changes opens directly in a normal editable view with no view-mode control, since there is nothing to diff.
|
||||||
- Editing follows the editor settings: indentation, indent width, trim trailing whitespace, insert final newline, word wrap, indent guides, whitespace rendering, and active-line highlight, all read from configuration (section 10).
|
- Editing follows the editor settings: indentation, indent width, trim trailing whitespace, insert final newline, word wrap, indent guides, whitespace rendering, and active-line highlight, all read from configuration (section 10).
|
||||||
- **Word wrap** follows `editor.wordWrap`: `markdown` (default), `on`, or `off`. When it is on, a line too wide for the pane folds onto the next row instead of scrolling sideways. Only the first row of a folded line carries a line number; the rows below it stay blank. Split view never wraps, because its two panes align row by row.
|
- **Word wrap** follows `editor.wordWrap`: `markdown` (default), `on`, or `off`. When it is on, a line too wide for the pane folds onto the next row instead of scrolling sideways. Only the first row of a folded line carries a line number; the rows below it stay blank. The original panel folds at the same points as the editor, so each old line stays level with its current line. Diff never wraps, because its two panes align row by row.
|
||||||
|
|
||||||
### Right-click in code
|
### Right-click in code
|
||||||
|
|
||||||
@@ -152,8 +155,8 @@ Right-clicking inside the code area, with or without a selection, shows a contex
|
|||||||
|
|
||||||
### File-state behavior in the view modes
|
### File-state behavior in the view modes
|
||||||
|
|
||||||
- **Added file**: only the Updated content exists. Original is empty, Diff shows everything as added (green), Split shows an empty left and the file on the right.
|
- **Added file**: only the Actual content exists. Actual marks every line as added, Original is empty, and Diff shows an empty left pane with the file on the right.
|
||||||
- **Deleted file**: only the Original content exists. Updated is empty, Diff shows everything as removed (red), Split shows the file on the left and an empty right.
|
- **Deleted file**: only the Original content exists. Actual is empty and has no line to mark, and Diff shows the file on the left with an empty right pane.
|
||||||
- **Binary or unsupported file**: cannot be shown as editable text. The editor shows a short placeholder stating the file cannot be displayed, and the view-mode control is not shown.
|
- **Binary or unsupported file**: cannot be shown as editable text. The editor shows a short placeholder stating the file cannot be displayed, and the view-mode control is not shown.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -186,7 +189,7 @@ The bottom pane of the fourth column.
|
|||||||
A bar across the bottom of the window. All items are **display only** for now; none are clickable.
|
A bar across the bottom of the window. All items are **display only** for now; none are clickable.
|
||||||
|
|
||||||
- On the left: the current branch and the working-tree line totals, for example `feat/payments-balance +50 -38`.
|
- On the left: the current branch and the working-tree line totals, for example `feat/payments-balance +50 -38`.
|
||||||
- On the right: cursor position (`Ln, Col`), indentation (`Spaces: 4`), encoding (`UTF-8`), language (`PHP`), and the active view mode (`Diff`).
|
- On the right: cursor position (`Ln, Col`), indentation (`Spaces: 4`), encoding (`UTF-8`), language (`PHP`), and the active view mode (`Actual`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
237
README.md
237
README.md
@@ -1,109 +1,216 @@
|
|||||||
# Helder
|
<div align="center">
|
||||||
|
|
||||||
**A dark, AI-first desktop code workbench for reviewing code written by an AI agent.**
|
# helder.
|
||||||
|
|
||||||
Helder is an Electron app that puts code review, git, and a live Claude Code agent side by side in one dense, IDE-style window. It opens one project per window, is dark-only by design (no light mode, no theme toggle), and is built around a single idea: make it effortless to point an AI agent at exactly the code you're looking at.
|
**A dark desktop workbench for developers who build with Claude Code.**
|
||||||
|
|
||||||
> **Status: working build.** The Electron app is scaffolded and everything above is implemented against the real filesystem, git, terminals, ripgrep search, and the `.helder/` config system. The editor is writable (save · autosave · discard). See [Getting started](#getting-started).
|
See what the agent changes, while it changes it — then hand it back exactly the line you mean.
|
||||||
|
|
||||||
|
[](#download)
|
||||||
|
[](https://electronjs.org)
|
||||||
|
[](https://react.dev)
|
||||||
|
[](https://typescriptlang.org)
|
||||||
|
[](#development)
|
||||||
|
|
||||||
|
[](#fully-vibe-coded)
|
||||||
|
|
||||||
|
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.16.png" alt="The Helder workspace: source control, explorer, editor and the Claude Code agent in four columns" width="100%">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Who this is for
|
||||||
|
|
||||||
|
You run Claude Code. The agent writes, edits and refactors while you watch the terminal scroll past. Two questions come back every few minutes: *what did it just change*, and *how do I point it at this exact line*.
|
||||||
|
|
||||||
|
Helder answers both in one window. The agent runs in the right column. The code it touches sits in the middle. The git working state sits on the left. Nothing is more than one keystroke away, and no window switch is necessary.
|
||||||
|
|
||||||
|
Helder is a review and hand-off surface, not a replacement for your editor. Keep PhpStorm or VS Code for the long build sessions. Open Helder for the sessions where the agent does the work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
Version **0.1.0**. Both builds are unsigned, so each platform asks once for permission.
|
||||||
|
|
||||||
|
| Platform | File | After the download |
|
||||||
|
|---|---|---|
|
||||||
|
| macOS · Apple Silicon | [`Helder-0.1.0-arm64.dmg`](releases/Helder-0.1.0-arm64.dmg) | Drag to `Applications`, then run `xattr -dr com.apple.quarantine /Applications/Helder.app` |
|
||||||
|
| Windows · x64 | [`helder-win-v0.1.0.zip`](releases/helder-win-v0.1.0.zip) | Unzip and run `Helder.exe`. SmartScreen shows "unrecognised app" — choose *More info · Run anyway* |
|
||||||
|
|
||||||
|
The `claude` CLI must be on your `PATH`. The agent pane starts it for you.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
A four-column workbench, left to right, between a thin title bar and status bar:
|
Four resizable columns, between a thin title bar and a status bar.
|
||||||
|
|
||||||
1. **Source Control** — git working state: commit box, staged list, changes list, per-file `+/−` counts. Stage, unstage, commit, and discard. (Push, pull, fetch, and branch switching are out of scope this version.)
|
| Column | What it holds |
|
||||||
2. **Explorer** — a VS Code-style file tree with colored file-type icons and inline change badges.
|
|---|---|
|
||||||
3. **Editor** — tabs plus syntax-highlighted code, with four diff view modes for changed files: **Original · Updated · Diff · Split** (Split goes full-screen, side by side).
|
| **A · Source Control** | Commit box, staged list, changes list, per-file `+/−` counts |
|
||||||
4. **Right column** — a live **Claude Code agent** terminal stacked over a normal shell terminal.
|
| **B · Explorer** | File tree with type icons and inline change badges |
|
||||||
|
| **C · Editor** | Tabs, syntax highlighting, and three views of every changed file |
|
||||||
|
| **D · Agent + Shell** | A live Claude Code terminal over a normal shell |
|
||||||
|
|
||||||
Every pane boundary is a draggable splitter; positions persist across launches.
|
Every boundary is a splitter. Positions survive a restart, together with your open tabs and view modes.
|
||||||
|
|
||||||
### The defining feature: Copy reference / Pass on to Agent
|
### Hand off a reference, not a description
|
||||||
|
|
||||||
Right-click in the editor to copy a project-relative `path:line` reference (e.g. `src/Http/Controller/UserController.php:42`), or **Pass on to Agent** — which inserts the reference into the agent's input *without submitting*, so you can stack several references before sending. Under the hood this uses bracketed paste into the agent's PTY, exactly how a real terminal paste behaves.
|
This is the feature the app exists for. Select code in the editor, press `⌘→`, and the reference lands in the agent's input **without a submit**:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/Http/Controller/UserController.php:42
|
||||||
|
```
|
||||||
|
|
||||||
|
Stack five of them, add your sentence, then press Enter once. Under the hood Helder writes a bracketed paste into the agent's PTY, so the `claude` CLI treats it as pasted text, exactly like a real terminal paste.
|
||||||
|
|
||||||
|
Right-click gives you the same reference on the clipboard. The Explorer offers it at file level.
|
||||||
|
|
||||||
|
### Three views of one change
|
||||||
|
|
||||||
|
Every changed file derives three views from one original-and-updated pair. `⌘M` cycles them. A markdown file adds **Preview**.
|
||||||
|
|
||||||
|
**Actual** · **Original** · **Diff**
|
||||||
|
|
||||||
|
**Actual** is the live buffer. It shows the file as it is now, with only the changed lines marked. Hover a marked line, and the whole original file appears over the agent column. It holds the matching scroll position, so the old line sits level with the new one.
|
||||||
|
|
||||||
|
**Diff** goes full screen over the whole app. The original is on the left, the updated file is on the right, and the lines align. `Esc` returns to the view you came from.
|
||||||
|
|
||||||
|
Added is teal, removed is amber-deep. There is no green and no red anywhere: the diff reads on shape and on a 2px rule, not on alarm.
|
||||||
|
|
||||||
|
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.30.png" alt="Diff view: original on the left, updated on the right, with the changed line marked" width="100%">
|
||||||
|
|
||||||
|
### Git, in the columns you already read
|
||||||
|
|
||||||
|
The Source Control column is a working git client for the review loop:
|
||||||
|
|
||||||
|
- Stage and unstage a single file, or the whole list.
|
||||||
|
- Write a commit message and commit with `⌘↵`.
|
||||||
|
- Discard a file back to `HEAD`, behind a confirmation.
|
||||||
|
- Push the current branch with `⌘P`.
|
||||||
|
- Watch `+/−` counts update as the agent writes.
|
||||||
|
|
||||||
|
Pull, fetch and branch switching stay out of scope. Helder reviews what the agent did; it does not manage your remotes.
|
||||||
|
|
||||||
### One search for everything
|
### One search for everything
|
||||||
|
|
||||||
`⌘F` / `Ctrl+F` opens a single modal that searches **both file contents and file names** at once — content matches on the left (ripgrep), fuzzy file-name matches on the right. There is intentionally no separate "go to file" command.
|
`⌘F` opens a single overlay that searches file **contents** and file **names** at the same time. Content matches come from ripgrep on the left, fuzzy name matches on the right. There is deliberately no second "go to file" command to remember.
|
||||||
|
|
||||||
|
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.21.png" alt="The search overlay and the keyboard shortcut reference" width="100%">
|
||||||
|
|
||||||
|
### The project note
|
||||||
|
|
||||||
|
`⌘N` opens a note that belongs to the project. Collect the things you want to tell the agent while you read. `⌘P` passes the whole note into the agent's input in one go.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech stack
|
## Keyboard
|
||||||
|
|
||||||
| Layer | Choice |
|
Press `?` in the title bar for the full list.
|
||||||
|-------|--------|
|
|
||||||
| Shell | Electron (latest stable), main + renderer + preload bridge (`contextIsolation: true`) |
|
|
||||||
| Renderer | React 18 + TypeScript + Vite (`electron-vite`) |
|
|
||||||
| Highlighting | Prism 1.29 (swappable to Shiki / CodeMirror 6) |
|
|
||||||
| Filesystem | `fs` + `chokidar`, via the main process |
|
|
||||||
| Git | `git` / `simple-git`, via the main process |
|
|
||||||
| Terminals | `node-pty` + `xterm.js` |
|
|
||||||
| Search | `ripgrep` (content) + a fuzzy matcher (file names) |
|
|
||||||
| Mono font | JetBrains Mono, bundled locally |
|
|
||||||
|
|
||||||
**Architecture rule:** the renderer never touches the filesystem, git, or PTYs directly. All of it goes through the main process over IPC / the preload bridge.
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| `⌘F` | Search contents and names |
|
||||||
|
| `⌘→` | Pass the selection to the agent |
|
||||||
|
| `⌘N` · `⌘P` | Open the project note · pass the whole note |
|
||||||
|
| `⌘M` | Cycle Actual · Original · Diff |
|
||||||
|
| `⌘C` · `⌘↵` | Focus the commit message · commit the staged files |
|
||||||
|
| `⌘P` | Push the current branch |
|
||||||
|
| `⌘S` | Save this file |
|
||||||
|
| `⌘G` · `⌘L` · `⌘.` | Source Control on or off · fluid columns · hidden files |
|
||||||
|
| `⌘O` · `⇧⌘O` | Open a project folder · open a recent project |
|
||||||
|
| `⌘←→` · `↑↓` · `↵` | Move between columns · move the row cursor · open the row |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting started
|
## Fully vibe coded
|
||||||
|
|
||||||
Requires Node 18+ and a recent `git` on your `PATH`.
|
Helder is **100% vibe coded**. Every line comes out of Claude Code sessions: the design board, the Electron main process, the React renderer, the 155 tests, the packaging scripts and this README. A human set the direction and judged the app from the outside — does it do the right thing, does it feel right. A human did not type the source.
|
||||||
|
|
||||||
```bash
|
**Nobody read the code.** There is no line-by-line review behind this repository. The guarantees are narrower than that: the test suite passes, typecheck and lint pass, and the app runs daily on real projects. Treat everything else as unverified.
|
||||||
npm install # also rebuilds node-pty for Electron (postinstall)
|
|
||||||
npm run dev # launch the app with hot reload
|
|
||||||
```
|
|
||||||
|
|
||||||
Helder opens **one project per window** — by default the current working directory. Open a different folder by clicking the project name in the title bar, or launch with `HELDER_PROJECT=/path/to/repo npm run dev`. The agent pane auto-runs the `claude` CLI, so it must be on your `PATH`.
|
That is also why the app exists. The workflow needed a tool, so the workflow built one.
|
||||||
|
|
||||||
### Scripts
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `npm run dev` | Launch in Electron with HMR |
|
|
||||||
| `npm run build` | Production build into `out/` |
|
|
||||||
| `npm start` | Run the built app |
|
|
||||||
| `npm test` | Run the vitest suite |
|
|
||||||
| `npm run lint` | ESLint |
|
|
||||||
| `npm run typecheck` | `tsc --noEmit` (renderer + main/preload) |
|
|
||||||
| `npm run pack` | Unpacked app into `dist/` (electron-builder) |
|
|
||||||
| `npm run dist` | Distributable (`.dmg` / `.zip` / etc.) |
|
|
||||||
| `npm run rebuild` | Re-rebuild `node-pty` for Electron if a terminal shows "PTY unavailable" |
|
|
||||||
|
|
||||||
To preview the original design prototype, open `design_handoff_helder_workbench/design/Helder - AI Code Workbench.html` in a browser — a clickable React-via-Babel mock with sample data.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Settings are project-scoped, in a `.helder/` folder in the opened project's root:
|
Settings are per project, in a `.helder/` folder in the project root.
|
||||||
|
|
||||||
- **`.helder/config.json`** — your overrides only (sparse).
|
| File | Role |
|
||||||
- **`.helder/config.default.json`** — the full default set, regenerated on every launch as live documentation of every setting.
|
|---|---|
|
||||||
- **`.helder/theme.css`** — a custom theme layered over the built-in dark theme; the code font and size live here.
|
| `config.json` | Your overrides only. Sparse |
|
||||||
|
| `config.default.json` | Every default, rewritten on each launch as live documentation |
|
||||||
|
| `theme.css` | A theme over the built-in dark one. The code font and size live here |
|
||||||
|
|
||||||
An effective setting is the value from `config.json` if present, otherwise from `config.default.json`, merged key by key.
|
An effective value comes from `config.json` when it is present, otherwise from `config.default.json`, merged key by key. A watcher reloads both while the app runs.
|
||||||
|
|
||||||
|
There is no light mode and no theme toggle. That is a decision, not a gap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repository layout
|
## Development
|
||||||
|
|
||||||
|
Node 18 or later, and `git` on your `PATH`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # rebuilds node-pty for Electron (postinstall)
|
||||||
|
npm run dev # launch with hot reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Helder opens one project per window, by default the working directory. Click the project name in the title bar to open another, or start with `HELDER_PROJECT=/path/to/repo npm run dev`.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Launch in Electron with HMR |
|
||||||
|
| `npm run build` | Production build into `out/` |
|
||||||
|
| `npm test` | The vitest suite |
|
||||||
|
| `npm run lint` | ESLint |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit`, renderer and main |
|
||||||
|
| `npm run rebuild` | Rebuild `node-pty` when a terminal says "PTY unavailable" |
|
||||||
|
|
||||||
|
Keep typecheck, lint, test and build green together. The build uses esbuild and does not type-check, so run `typecheck` on its own.
|
||||||
|
|
||||||
|
### Package a release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./sync_helder.sh # macOS: build, install into ~/Applications, copy the DMG
|
||||||
|
npm run build && npx electron-builder --win -c.npmRebuild=false # Windows, cross-built from macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows build needs `node_modules/@vscode/ripgrep-win32-x64`, which npm refuses to install on macOS. Extract it by hand once; `npm ci` removes it again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Shell | Electron 31, main + renderer + preload bridge (`contextIsolation: true`) |
|
||||||
|
| Renderer | React 18, TypeScript, Vite through `electron-vite` |
|
||||||
|
| Highlighting | Prism 1.29 |
|
||||||
|
| Filesystem | `fs` + `chokidar` v3, in the main process |
|
||||||
|
| Git | `simple-git`, in the main process |
|
||||||
|
| Terminals | `node-pty` + `xterm.js` |
|
||||||
|
| Search | ripgrep for content, a fuzzy matcher for names |
|
||||||
|
| Type | IBM Plex Mono for code and labels, IBM Plex Sans for the interface. Both bundled |
|
||||||
|
|
||||||
|
**The one rule:** the renderer never touches the filesystem, git or a PTY. Everything crosses the preload bridge over IPC. Break that and the app stops being safe to run against a real repository.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main/ Electron main process: window + IPC + services
|
main/ window, IPC, services (fs · git · pty · search · config · logger)
|
||||||
(fs-service, git-service, pty-service, search-service, config, project)
|
preload/ the contextIsolation bridge — the only renderer↔OS surface
|
||||||
preload/ contextIsolation bridge — the only renderer↔OS surface (window.helder)
|
renderer/ React UI: App, editor, terminals, overlays, project store, helpers
|
||||||
renderer/ React UI: App, editor (4 diff modes + writable buffer), terminals (xterm),
|
test/ vitest — diff, fuzzy, highlight, config, fs, git, grouping, UI
|
||||||
overlays (search/menu/toasts), project store, diff/highlight/fuzzy helpers
|
docs/design/ the design board, tokens and screens at real pixel size
|
||||||
test/ vitest suite — diff, fuzzy, highlight, config, fs, git
|
DESIGN.md functional spec: every panel, state and interaction
|
||||||
electron.vite.config.ts electron-builder.yml eslint.config.js vitest.config.ts
|
CLAUDE.md architecture notes and the traps, for the next agent session
|
||||||
DESIGN.md Functional/UX spec — every panel, state, interaction
|
|
||||||
CLAUDE.md Guidance + current architecture for Claude Code
|
|
||||||
design_handoff_helder_workbench/ Original design handoff + clickable prototype
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`DESIGN.md` and `design_handoff_helder_workbench/README.md` remain the design source of truth; the prototype is a visual reference, not shipped.
|
`docs/design/README.md` is the visual source of truth. `DESIGN.md` is the behavioural one.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
281
design_handoff_helder_inline_diff/03b-in-pane-diff.html
Normal file
281
design_handoff_helder_inline_diff/03b-in-pane-diff.html
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Helder — 3b · in-pane diff (design reference)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #EFF0EE; padding: 40px; font-family: 'IBM Plex Sans', sans-serif; color: #171C22; }
|
||||||
|
@keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div data-screen-label="03b Diff in pane" style="width:1760px;height:1000px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column;color:#E4E7E6">
|
||||||
|
|
||||||
|
<div style="height:44px;flex:none;background:#18202B;border-bottom:1px solid #232C39;display:flex;align-items:center;gap:18px;padding:0 14px">
|
||||||
|
<div style="display:flex;gap:8px;flex:none">
|
||||||
|
<span style="width:12px;height:12px;border-radius:9999px;background:#E8913A"></span>
|
||||||
|
<span style="width:12px;height:12px;border-radius:9999px;background:#6C7783"></span>
|
||||||
|
<span style="width:12px;height:12px;border-radius:9999px;background:#3A424C"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:baseline;gap:10px;flex:none">
|
||||||
|
<span style="font:700 13px/1 'IBM Plex Mono',monospace;letter-spacing:.02em;color:#F4F5F4">helder<span style="color:#E8913A;animation:blink 1.1s steps(1,end) infinite">.</span></span>
|
||||||
|
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">bob</span>
|
||||||
|
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">·</span>
|
||||||
|
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#E8913A">bob2</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:7px;font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783;flex:1;min-width:0">
|
||||||
|
<span>agents</span><span>›</span><span>mail-checker</span><span>›</span>
|
||||||
|
<span style="color:#F4F5F4;font-weight:600">README.md</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;flex:none">
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Fluid <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘L</span></span>
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
|
||||||
|
<span style="width:26px;height:26px;margin-left:6px;border:1px solid #232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783">?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;min-height:0;display:flex;position:relative">
|
||||||
|
|
||||||
|
<!-- source control -->
|
||||||
|
<div style="width:300px;flex:none;background:#18202B;border-right:1px solid #232C39;display:flex;flex-direction:column">
|
||||||
|
<div style="height:46px;flex:none;box-sizing:border-box;padding:0 12px;display:flex;gap:8px;align-items:center;border-bottom:1px solid #232C39">
|
||||||
|
<div style="flex:1;min-width:0;height:30px;background:#101720;border:1px solid #232C39;border-radius:2px;padding:0 9px;display:flex;align-items:center;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">runs mail check once an hour</div>
|
||||||
|
<div style="width:30px;height:30px;flex:none;border:1px solid #232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:400 15px/1 'IBM Plex Mono',monospace;color:#BAC0C0">↑</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px 12px 6px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;display:flex;justify-content:space-between">
|
||||||
|
<span>staged changes</span><span style="color:#E8913A">3</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column">
|
||||||
|
<div style="height:22px;display:flex;align-items:center;gap:7px;padding:0 12px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;overflow:hidden"><span style="flex:none">▤</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">agents/mail-checker</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;background:#232C39;border-left:2px solid #E8913A"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span style="font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">README.md</span></div>
|
||||||
|
<div style="height:22px;display:flex;align-items:center;gap:7px;padding:0 12px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;overflow:hidden"><span style="flex:none">▤</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">scheduler</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 12px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#F8C793;border-radius:2px;padding:4px 5px">yml</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">scheduler.yaml</span></div>
|
||||||
|
<div style="height:22px;display:flex;align-items:center;gap:7px;padding:0 12px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;overflow:hidden"><span style="flex:none">▤</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">state</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 12px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#A8D6CB;border-radius:2px;padding:4px 5px">tsv</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">skill-usage.tsv</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:16px 12px 0;border-top:1px solid #232C39"></div>
|
||||||
|
<div style="padding:14px 12px 6px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;display:flex;justify-content:space-between">
|
||||||
|
<span>changes</span><span style="color:#E8913A">3</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column">
|
||||||
|
<div style="height:22px;display:flex;align-items:center;gap:7px;padding:0 12px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;overflow:hidden"><span style="flex:none">▤</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">.</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 12px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#F0B476;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">CLAUDE.md</span></div>
|
||||||
|
<div style="height:22px;display:flex;align-items:center;gap:7px;padding:0 12px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;overflow:hidden"><span style="flex:none">▤</span><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">scripts</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 12px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#F0B476;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">README.md</span></div>
|
||||||
|
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 12px;border-left:2px solid transparent"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#F0B476;width:12px">M</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#EFBCB0;border-radius:2px;padding:4px 5px">sh</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">check-docs-rot.sh</span></div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div style="padding:12px;border-top:1px solid #232C39;font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783">3 staged · 3 unstaged</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- explorer -->
|
||||||
|
<div style="width:288px;flex:none;background:#18202B;border-right:1px solid #232C39;display:flex;flex-direction:column;overflow:hidden">
|
||||||
|
<div style="height:46px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 12px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span>explorer</span><span>bob</span></div>
|
||||||
|
<div style="flex:1;min-height:0;padding:6px 0;display:flex;flex-direction:column;font:400 13px/1 'IBM Plex Sans',sans-serif;overflow:hidden">
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#828D9A">▤</span>agents</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>knowledge-researcher</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>lead-agent</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#828D9A">▤</span>mail-checker</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>PROMPT.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 58px;color:#F4F5F4;background:#232C39;border-left:2px solid #E8913A"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span style="font-weight:500">README.md</span><span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>STATE.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>ok-monitor</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>sterrenkijker</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>tony</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>README.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>assets</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>data</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>docker</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>logs</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#828D9A">▤</span>scheduler</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>prompts</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>README.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>runs-2026H2.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>runs.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#F8C793;border-radius:2px;padding:4px 5px">yml</span><span>scheduler.yaml</span><span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#EFBCB0;border-radius:2px;padding:4px 5px">sh</span><span>test.sh</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#EFBCB0;border-radius:2px;padding:4px 5px">sh</span><span>tick.sh</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#828D9A">▤</span>scripts</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 12px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#828D9A">▤</span>state</div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:4px 5px">json</span><span>mail-state.json</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:4px 5px">json</span><span>read-counts.json</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>README.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#A8D6CB;border-radius:2px;padding:4px 5px">tsv</span><span>skill-usage.tsv</span><span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>CLAUDE.md</span><span style="flex:1"></span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">M</span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>GUARDRAILS.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>LOOP.md</span><span style="flex:1"></span></div>
|
||||||
|
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 28px;color:#BAC0C0"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:4px 5px">md</span><span>MEMORY.md</span><span style="flex:1"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- editor · diff -->
|
||||||
|
<div style="flex:1;min-width:0;background:#101720;display:flex;flex-direction:column;overflow:hidden">
|
||||||
|
<div style="height:46px;flex:none;display:flex;align-items:center;border-bottom:1px solid #232C39">
|
||||||
|
<span style="width:46px;flex:none;text-align:center;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">bob</span>
|
||||||
|
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:12px;padding:0 12px">
|
||||||
|
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">modified</span>
|
||||||
|
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#8FBFB4">+2</span>
|
||||||
|
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#C4741F">−2</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<div style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
|
||||||
|
<span style="padding:5px 12px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A">Actual</span>
|
||||||
|
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Original</span>
|
||||||
|
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Preview</span>
|
||||||
|
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Diff</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-height:0;display:flex;overflow:hidden">
|
||||||
|
<div style="width:46px;flex:none;border-right:1px solid #232C39;display:flex;flex-direction:column;padding-top:8px;font:600 10px/1 'IBM Plex Mono',monospace;color:#8FBFB4">
|
||||||
|
<span style="height:110px"></span>
|
||||||
|
<span style="height:20px;display:flex;align-items:center;justify-content:center;background:#232C39">M</span>
|
||||||
|
<span style="height:340px"></span>
|
||||||
|
<span style="height:20px;display:flex;align-items:center;justify-content:center;color:#C4741F">M</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:0;display:grid;grid-template-columns:44px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:8px 0">
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">1</span><span style="padding-right:18px;color:#F4F5F4;text-wrap:pretty"># mail-checker — how I dispatch it</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">2</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">3</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">This file is for me (Bob), the dispatcher. It says when to run this agent and how. The agent's own prompt is <span style="color:#A8D6CB">[`PROMPT.md`](PROMPT.md)</span>. I do not read that file myself.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">6</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">7</span><span style="padding-right:18px;color:#F4F5F4;text-wrap:pretty">## What it does</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">8</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">9</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">It works through imported mails that no AI has looked at yet. Per mail it does three things:</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">11</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">12</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">1. Judges whether Jonathan should actually read it.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">13</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">2. Cleans the body into readable Markdown.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">14</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">3. Makes a todo when the mail asks for a concrete action.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">15</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">16</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">New people it meets get a Vault note, so Blijnder remembers them.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">17</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">18</span><span style="padding-right:18px;color:#F4F5F4;text-wrap:pretty">## The hard boundary</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">19</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">20</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">It reads, cleans, classifies and records. It never replies to a mail, never deletes a mail, and never chooses a recipient. Nothing that it touches leaves Blijnder, and there is no exception to that.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">23</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">24</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">Between 2026-08-22 and 2026-08-27 there was one: an invoice went to the administration through a script of its own. Skynet does that job now (skynet#41), thus the workspace gave it up (bob#371). The agent is mute again, and the outbound gate refuses every command that would speak for it.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">28</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">29</span><span style="padding-right:18px;color:#F4F5F4;text-wrap:pretty">## When to dispatch</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">30</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#8FBFB4;background:rgba(143,191,180,.12);border-left:2px solid #8FBFB4">31</span><span id="changed-line" style="padding-right:18px;color:#F4F5F4;background:rgba(143,191,180,.12);text-wrap:pretty">- The scheduler fires it. Job <span style="color:#F0B476">`mail-checker-ronde`</span>, every hour, on the hour.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">32</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">- Jonathan asks for a mail sweep.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">33</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">- The mail step of my tick shows unchecked mail piling up.</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">34</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">35</span><span style="padding-right:18px;color:#F4F5F4;text-wrap:pretty">## How to dispatch</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">36</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">37</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">Send a subagent the <span style="color:#F4F5F4;font-weight:600">path</span>, not the content:</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">38</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">39</span><span style="padding-right:18px;color:#6C7783;text-wrap:pretty">```</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">40</span><span style="padding-right:18px;color:#BAC0C0;text-wrap:pretty">Agent tool → "Read agents/mail-checker/PROMPT.md and follow it."</span>
|
||||||
|
<span style="text-align:right;padding-right:12px;color:#5A6472">41</span><span style="padding-right:18px;color:#6C7783;text-wrap:pretty">```</span>
|
||||||
|
</div>
|
||||||
|
<div style="width:16px;flex:none;border-left:1px solid #232C39;display:flex;flex-direction:column;align-items:center;padding-top:8px">
|
||||||
|
<span style="height:104px"></span>
|
||||||
|
<span style="width:4px;height:4px;background:#6C7783"></span>
|
||||||
|
<span style="height:64px"></span>
|
||||||
|
<span style="width:4px;height:4px;background:#8FBFB4"></span>
|
||||||
|
<span style="height:184px"></span>
|
||||||
|
<span style="width:4px;height:4px;background:#6C7783"></span>
|
||||||
|
<span style="height:118px"></span>
|
||||||
|
<span style="width:4px;height:4px;background:#C4741F"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- agent -->
|
||||||
|
<div style="width:480px;flex:none;background:#101720;border-left:1px solid #232C39;display:flex;flex-direction:column;overflow:hidden">
|
||||||
|
<div style="flex:1;min-height:0;padding:14px 16px;display:flex;flex-direction:column;gap:12px;overflow:hidden;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#BAC0C0">
|
||||||
|
<div style="font:400 12px/1.6 'IBM Plex Mono',monospace;color:#6C7783;text-wrap:pretty">98:| <span style="color:#F0B476">[`check-docs-rot.sh`](check-docs-rot.sh)</span> | It checks my rule files for references that are not correct any more: a script or a flag that does not exist, a dead markdown link, an orphan script, an</div>
|
||||||
|
<div style="font:400 12px/1.6 'IBM Plex Mono',monospace;color:#6C7783">└ Allowed by auto mode classifier</div>
|
||||||
|
<div style="display:flex;gap:10px"><span style="color:#6C7783">●</span><span style="text-wrap:pretty">The column is gone. <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">CLAUDE.md</span> now names which agents exist, and nothing about their clock.</span></div>
|
||||||
|
<div style="text-wrap:pretty"><span style="font-weight:600;color:#F4F5F4">Why it was there.</span> The table was written as one map with three columns, and <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">check-docs-rot.sh</span> section L then enforced the third against the cron lines. That made the coupling real: each change of a schedule broke the check until somebody opened a pull request on the root.</div>
|
||||||
|
<div style="text-wrap:pretty"><span style="font-weight:600;color:#F4F5F4">What changed.</span> The table is two columns. Below it stands one rule: when a job runs, and whether it runs at all, stands in <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">scheduler/scheduler.yaml</span> and only there. Section L still holds the table against <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">agents/</span> in both directions, and it no longer reads a clock.</div>
|
||||||
|
<div style="text-wrap:pretty"><span style="font-weight:600;color:#F4F5F4">The check is cleaner now.</span> It reported <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">knowledge-researcher</span> and <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">sterrenkijker</span> as wrong; both were this same duplication, and both are gone.</div>
|
||||||
|
<div style="text-wrap:pretty">Five files stand changed and uncommitted. <span style="color:#F0B476;font-family:'IBM Plex Mono',monospace">CLAUDE.md</span> is a root file, so it needs a pull request when you commit it.</div>
|
||||||
|
<div style="font:400 12px/1.6 'IBM Plex Mono',monospace;color:#6C7783">✳ Churned for 2m 49s · done 8:10 AM</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin:0 16px 12px;height:44px;flex:none;background:#101720;border:2px solid #E8913A;box-shadow:0 0 0 2px rgba(232,145,58,.22);border-radius:2px;display:flex;align-items:center;gap:8px;padding:0 12px">
|
||||||
|
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A">›</span>
|
||||||
|
<span style="width:1px;height:16px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
|
||||||
|
|
||||||
|
<!-- hover: original of the whole file, same scroll -->
|
||||||
|
<!-- id added for the demo script -->
|
||||||
|
<div id="original-overlay" style="display:none;position:absolute;z-index:20;top:0;right:0;bottom:0;width:496px;background:#101720;border-left:2px solid #C4741F;box-shadow:0 4px 16px rgba(23,28,34,.10);flex-direction:column;overflow:hidden">
|
||||||
|
<div style="height:46px;flex:none;display:flex;align-items:center;gap:12px;padding:0 14px;border-bottom:1px solid #232C39;background:#18202B">
|
||||||
|
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">original</span>
|
||||||
|
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">before this change · same scroll</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">hold hover</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-height:0;display:grid;grid-template-columns:35px 1fr;align-content:start;font:400 10.3px/20px 'IBM Plex Mono',monospace;padding:8px 0;overflow:hidden">
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">1</span><span style="padding-right:11px;color:#F4F5F4;text-wrap:pretty"># mail-checker — how I dispatch it</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">2</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">3</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">This file is for me (Bob), the dispatcher. It says when to run this agent and how. The agent's own prompt is <span style="color:#A8D6CB">[`PROMPT.md`](PROMPT.md)</span>. I do not read that file myself.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">6</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">7</span><span style="padding-right:11px;color:#F4F5F4;text-wrap:pretty">## What it does</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">8</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">9</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">It works through imported mails that no AI has looked at yet. Per mail it does three things:</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">11</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">12</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">1. Judges whether Jonathan should actually read it.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">13</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">2. Cleans the body into readable Markdown.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">14</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">3. Makes a todo when the mail asks for a concrete action.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">15</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">16</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">New people it meets get a Vault note, so Blijnder remembers them.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">17</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">18</span><span style="padding-right:11px;color:#F4F5F4;text-wrap:pretty">## The hard boundary</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">19</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">20</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">It reads, cleans, classifies and records. It never replies to a mail, never deletes a mail, and never chooses a recipient. Nothing that it touches leaves Blijnder, and there is no exception to that.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">23</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">24</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">Between 2026-08-22 and 2026-08-27 there was one: an invoice went to the administration through a script of its own. Skynet does that job now (skynet#41), thus the workspace gave it up (bob#371). The agent is mute again, and the outbound gate refuses every command that would speak for it.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">28</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">29</span><span style="padding-right:11px;color:#F4F5F4;text-wrap:pretty">## When to dispatch</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">30</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#C4741F;background:rgba(196,116,31,.14);border-left:2px solid #C4741F">31</span><span style="padding-right:11px;color:#E4E7E6;background:rgba(196,116,31,.14);text-wrap:pretty">- The scheduler fires it. Job <span style="color:#F0B476">`mail-checker-ronde`</span>, every 15 minutes.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">32</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">- Jonathan asks for a mail sweep.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">33</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">- The mail step of my tick shows unchecked mail piling up.</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">34</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">35</span><span style="padding-right:11px;color:#F4F5F4;text-wrap:pretty">## How to dispatch</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">36</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">37</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">Send a subagent the <span style="color:#F4F5F4;font-weight:600">path</span>, not the content:</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">38</span><span></span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">39</span><span style="padding-right:11px;color:#6C7783;text-wrap:pretty">```</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">40</span><span style="padding-right:11px;color:#BAC0C0;text-wrap:pretty">Agent tool → "Read agents/mail-checker/PROMPT.md and follow it."</span>
|
||||||
|
<span style="text-align:right;padding-right:9px;color:#5A6472">41</span><span style="padding-right:11px;color:#6C7783;text-wrap:pretty">```</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:0 16px 12px;display:flex;flex-direction:column;gap:6px;font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px">
|
||||||
|
<span>[Opus 5 · 1M]</span>
|
||||||
|
<span style="color:#171C22;background:#E8913A;border-radius:2px;padding:3px 6px;font-weight:600">bob</span>
|
||||||
|
<span>|</span><span style="color:#A8D6CB">⑂ bob2</span>
|
||||||
|
<span style="flex:1"></span><span>/rc</span>
|
||||||
|
</div>
|
||||||
|
<div><span style="color:#E8913A">▸▸ auto mode on</span> (shift+tab to cycle) · ← for agents</div>
|
||||||
|
</div>
|
||||||
|
<div style="height:150px;flex:none;background:#101720;border-top:1px solid #232C39;padding:12px 16px;font:400 12px/1.7 'IBM Plex Mono',monospace;color:#BAC0C0">
|
||||||
|
<div><span style="color:#A8D6CB">[bob2]</span><span style="color:#828D9A">[~/code/bob]$</span> <span style="display:inline-block;width:7px;height:14px;background:#E8913A;vertical-align:-2px;animation:blink 1.1s steps(1,end) infinite"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:28px;flex:none;background:#18202B;border-top:1px solid #232C39;display:flex;align-items:center;gap:16px;padding:0 14px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
|
||||||
|
<span style="color:#E8913A">⑂ bob2</span>
|
||||||
|
<span style="color:#A8D6CB">+22</span><span style="color:#C4741F">−66</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<span>agents/mail-checker/README.md</span>
|
||||||
|
<span>·</span><span>Ln 1, Col 1</span><span>·</span><span>UTF-8</span><span>·</span><span>LF</span><span>·</span><span style="color:#D4D9D8">Markdown</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var row = document.getElementById('changed-line');
|
||||||
|
var overlay = document.getElementById('original-overlay');
|
||||||
|
row.addEventListener('mouseenter', function () { overlay.style.display = 'flex'; });
|
||||||
|
row.addEventListener('mouseleave', function () { overlay.style.display = 'none'; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
245
design_handoff_helder_inline_diff/README.md
Normal file
245
design_handoff_helder_inline_diff/README.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Handoff: Helder — in-pane diff (screen 3b)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Helder is a desktop editor for working with Claude Code. This handoff covers **one screen**: the
|
||||||
|
workspace with a modified file open in the editor pane, in **Actual** view.
|
||||||
|
|
||||||
|
The idea behind the screen: the user always reads the **current** version of the file. A changed
|
||||||
|
line is marked in place with a teal rule and a teal tint — nothing else moves, no split view, no
|
||||||
|
`+`/`−` line pairs. When the user hovers a changed line, the **whole original file** slides in over
|
||||||
|
the right-hand column (agent + terminal) at the **same scroll position**, so the two versions can be
|
||||||
|
compared line by line. The overlay disappears on mouse-out. There is no mode to enter and no mode to
|
||||||
|
leave.
|
||||||
|
|
||||||
|
## About the design files
|
||||||
|
|
||||||
|
`03b-in-pane-diff.html` in this folder is a **design reference created in HTML** — a prototype that
|
||||||
|
shows the intended look and behaviour. It is not production code to copy.
|
||||||
|
|
||||||
|
The task is to **recreate this design in the target codebase's own environment** (Electron + React,
|
||||||
|
Tauri, SwiftUI, whatever Helder already uses) with that codebase's established patterns, components
|
||||||
|
and styling layer. If no environment exists yet, choose the framework that fits the product and
|
||||||
|
implement the design there.
|
||||||
|
|
||||||
|
The HTML uses inline styles throughout because of how the design tool works. Do not treat that as a
|
||||||
|
styling instruction — move the values into the codebase's own token/theme layer.
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High fidelity.** Colours, type, spacing, row heights and states below are final and exact.
|
||||||
|
Recreate the UI pixel-perfectly. The only intentionally loose part is the file-tree and
|
||||||
|
source-control content, which is sample data.
|
||||||
|
|
||||||
|
## Screen
|
||||||
|
|
||||||
|
**Name:** Workspace — file modified, Actual view
|
||||||
|
**Canvas:** 1760 × 1000 px (design size; the real window is resizable — see *Responsive behaviour*)
|
||||||
|
**Purpose:** read and edit a file that has uncommitted changes, while Claude Code works in the right
|
||||||
|
column; check what any changed line used to be without leaving the file.
|
||||||
|
|
||||||
|
### Column layout (left → right)
|
||||||
|
|
||||||
|
| Region | Width | Background | Divider |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Source control | 300 px, fixed | `#18202B` | 1 px `#232C39` right |
|
||||||
|
| Explorer | 288 px, fixed | `#18202B` | 1 px `#232C39` right |
|
||||||
|
| Editor | flexible (692 px at 1760) | `#101720` | — |
|
||||||
|
| Agent + terminal | 480 px, fixed | `#101720` | 1 px `#232C39` left |
|
||||||
|
|
||||||
|
Vertical stack: title bar 44 px → body row (flex: 1) → status bar 28 px.
|
||||||
|
|
||||||
|
### Title bar — 44 px, `#18202B`, 1 px `#232C39` bottom
|
||||||
|
|
||||||
|
- Traffic lights: three 12 px circles, `#E8913A` / `#6C7783` / `#3A424C`, 8 px gap, 14 px from left.
|
||||||
|
- Wordmark `helder.` — IBM Plex Mono 700 13 px `#F4F5F4`; **the period is `#E8913A` and blinks**
|
||||||
|
(see *Animation*).
|
||||||
|
- Repo · worktree: `bob` `#6C7783` · `bob2` `#E8913A` 500, Plex Mono 12 px.
|
||||||
|
- Breadcrumb: Plex Mono 12 px `#6C7783`, `›` separators, last crumb `#F4F5F4` 600.
|
||||||
|
- Right cluster, each 6 px 9 px padding, Plex Sans 12 px label + Plex Mono 12 px 600 shortcut chip
|
||||||
|
(1 px `#232C39`, radius 2, padding 4 px 6 px): Search ⌘F · Fluid ⌘L · Hidden ⌘. (label and chip
|
||||||
|
`#6C7783` — the off state) · Note ⌘N · **Git ⌘G active**: background `rgba(232,145,58,.10)`, label
|
||||||
|
`#F4F5F4`, chip border `rgba(232,145,58,.35)`. Then a 26 × 26 `?` button, 1 px `#232C39`.
|
||||||
|
|
||||||
|
### The 46 px header row
|
||||||
|
|
||||||
|
All three panel headers sit on **one 46 px row** with their 1 px `#232C39` bottom borders on the
|
||||||
|
same line. This alignment is deliberate — do not let any one of them grow.
|
||||||
|
|
||||||
|
1. **Source control commit row** — 46 px, padding 0 12 px, flex, 8 px gap, centred.
|
||||||
|
Commit field: flex 1, height 30 px, `#101720`, 1 px `#232C39`, radius 2, padding 0 9 px,
|
||||||
|
Plex Sans 12 px `#E4E7E6`. Sample value: `runs mail check once an hour`.
|
||||||
|
Push button: 30 × 30, 1 px `#232C39`, radius 2, glyph `↑` Plex Mono 15 px `#BAC0C0`.
|
||||||
|
2. **Explorer header** — 46 px, padding 0 12 px, space-between, Plex Mono 700 10 px, tracking
|
||||||
|
`.14em`, uppercase, `#6C7783`: `EXPLORER` … `BOB`.
|
||||||
|
3. **Editor header** — 46 px. A 46 px-wide agent-rail cell first (centred `BOB`, same label style),
|
||||||
|
then: `MODIFIED` (Plex Mono 700 10 px uppercase `#6C7783`), `+2` `#8FBFB4`, `−2` `#C4741F` (both
|
||||||
|
Plex Mono 500 12 px), spacer, then the view switch.
|
||||||
|
|
||||||
|
**View switch** — a single bordered group, 1 px `#232C39`, radius 2, `overflow: hidden`, each item
|
||||||
|
5 px 12 px, Plex Sans 11 px, dividers 1 px `#232C39` between items:
|
||||||
|
`Actual` (**active**: `#E8913A` fill, `#171C22` text, weight 500) · `Original` · `Preview` · `Diff`
|
||||||
|
(inactive: `#BAC0C0`). Actual is the default and stays the default.
|
||||||
|
|
||||||
|
### Source control panel
|
||||||
|
|
||||||
|
Section header: padding 14 px 12 px 6 px, Plex Mono 700 10 px tracking `.14em` uppercase `#6C7783`,
|
||||||
|
count on the right in `#E8913A`.
|
||||||
|
Folder line: 22 px, `▤` + path, Plex Mono 11 px `#6C7783`, ellipsis on overflow.
|
||||||
|
File row: 26 px, 8 px gap — status letter (Plex Mono 600 10 px, 12 px wide; **staged `#8FBFB4`,
|
||||||
|
unstaged `#F0B476`**), file-type chip (Plex Mono 600 10 px, `#101720` text, radius 2, padding 4 px
|
||||||
|
5 px; `md` `#BAC0C0`, `yml` `#F8C793`, `tsv` `#A8D6CB`, `sh` `#EFBCB0`, `json`/`php` `#C3A6CE`,
|
||||||
|
`js` `#F0B476`), then the file name Plex Sans 12 px `#BAC0C0`.
|
||||||
|
**Selected row:** background `#232C39`, 2 px `#E8913A` left border, left padding reduced by 2 px so
|
||||||
|
the text does not shift, name `#F4F5F4` 500. Unselected rows carry `border-left: 2px solid
|
||||||
|
transparent` so nothing moves on selection.
|
||||||
|
Footer: 12 px padding, 1 px `#232C39` top, Plex Mono 11 px `#6C7783`, `3 staged · 3 unstaged`.
|
||||||
|
|
||||||
|
### Explorer
|
||||||
|
|
||||||
|
Rows are **24 px**. Folder: 8 px-wide chevron (`⌄` open `#E8913A`, `›` closed `#6C7783`), `▤`
|
||||||
|
`#828D9A`, name Plex Sans 13 px (`#F4F5F4` when open, `#BAC0C0` when closed). Indent = 12 px + 16 px
|
||||||
|
per level. Files use the same type chips as source control; a modified file shows a trailing `M`
|
||||||
|
(Plex Mono 600 10 px `#8FBFB4`). Selected file: `#232C39`, 2 px `#E8913A` left border, name 500
|
||||||
|
`#F4F5F4`.
|
||||||
|
|
||||||
|
### Editor pane
|
||||||
|
|
||||||
|
- **Agent rail** — 46 px, 1 px `#232C39` right. Holds per-block `M` markers aligned to the changed
|
||||||
|
region: a marker is Plex Mono 600 10 px, 20 px tall, centred; the active block is `#8FBFB4` on
|
||||||
|
`#232C39`, an unstaged block is `#C4741F` on transparent.
|
||||||
|
- **Code grid** — `grid-template-columns: 44px 1fr`, `align-content: start`, padding 8 px 0,
|
||||||
|
Plex Mono 400 **13 px / 20 px**.
|
||||||
|
Line numbers: right-aligned, padding-right 12 px, `#5A6472`.
|
||||||
|
Line text: padding-right 18 px, `#BAC0C0`; markdown headings `#F4F5F4`; fenced-code markers
|
||||||
|
`#6C7783`; inline code and links `#F0B476`; a markdown link target `#A8D6CB`. Long lines **wrap**
|
||||||
|
(`text-wrap: pretty`) and keep their single line number — wrapped rows are how the pane behaves at
|
||||||
|
any width.
|
||||||
|
- **Changed line (the core of this screen)** — line number `#8FBFB4` on `rgba(143,191,180,.12)` with
|
||||||
|
a **2 px `#8FBFB4` left border**; the text cell has the same `rgba(143,191,180,.12)` tint and text
|
||||||
|
`#F4F5F4`. No `+` glyph, no second row, no strikethrough. This is the current content, simply
|
||||||
|
marked as changed.
|
||||||
|
- **Minimap strip** — 16 px, 1 px `#232C39` left, 4 × 4 px marks: `#6C7783` normal, `#8FBFB4` staged
|
||||||
|
change, `#C4741F` unstaged change.
|
||||||
|
|
||||||
|
### Agent + terminal column (480 px)
|
||||||
|
|
||||||
|
Conversation body: padding 14 px 16 px, 12 px gap, Plex Sans 13 px / 1.6 `#BAC0C0`; tool output and
|
||||||
|
metadata in Plex Mono 12 px / 1.6 `#6C7783`; file paths and identifiers inline in Plex Mono `#F0B476`;
|
||||||
|
lead-ins (`Why it was there.`) `#F4F5F4` 600.
|
||||||
|
Prompt field: 44 px, `#101720`, **2 px `#E8913A` border + `0 0 0 2px rgba(232,145,58,.22)` ring**
|
||||||
|
(the house focus state), radius 2, `›` `#E8913A`, 1 × 16 px `#E8913A` caret that blinks.
|
||||||
|
Status lines: Plex Mono 11 px `#6C7783`; `bob` chip = `#E8913A` fill, `#171C22` text, radius 2;
|
||||||
|
`⑂ bob2` `#A8D6CB`; `▸▸ auto mode on` `#E8913A`.
|
||||||
|
Terminal: 150 px, 1 px `#232C39` top, Plex Mono 12 px / 1.7; prompt `[bob2]` `#A8D6CB`,
|
||||||
|
`[~/code/bob]$` `#828D9A`, 7 × 14 px `#E8913A` blinking block cursor.
|
||||||
|
|
||||||
|
### Status bar — 28 px, `#18202B`, 1 px `#232C39` top
|
||||||
|
|
||||||
|
Plex Mono 11 px `#6C7783`, 16 px gap, 0 14 px padding: `⑂ bob2` `#E8913A` · `+22` `#A8D6CB` ·
|
||||||
|
`−66` `#C4741F` · spacer · file path · `·` · `Ln 1, Col 1` · `UTF-8` · `LF` · language `#D4D9D8`.
|
||||||
|
|
||||||
|
## Interactions & behaviour
|
||||||
|
|
||||||
|
### The hover reveal (the one behaviour to get right)
|
||||||
|
|
||||||
|
- **Trigger:** `mouseenter` anywhere on a changed line's text cell. **Dismiss:** `mouseleave`.
|
||||||
|
No click, no pin, no delay in the prototype. If you add a delay, keep it under 120 ms in and 0 ms
|
||||||
|
out — the gesture must feel like looking, not like opening something.
|
||||||
|
- **What appears:** a panel pinned to the **top, right and bottom of the body row**, 496 px wide, so
|
||||||
|
it covers the whole agent + terminal column and nothing else. `#101720`, **2 px `#C4741F` left
|
||||||
|
border**, `box-shadow: 0 4px 16px rgba(23,28,34,.10)`, `overflow: hidden`, `z-index` above the
|
||||||
|
agent column.
|
||||||
|
- **Panel header:** 46 px (the same 46 px as the editor header, so the first code line of both panes
|
||||||
|
starts at the same y), `#18202B`, 1 px `#232C39` bottom: `ORIGINAL` Plex Mono 700 10 px tracking
|
||||||
|
`.14em` `#C4741F`, then `before this change · same scroll` Plex Mono 11 px `#6C7783`, then
|
||||||
|
`hold hover` right-aligned in the same muted style.
|
||||||
|
- **Panel body:** the **whole original file**, not a hunk — same lines, same order, same wrap points
|
||||||
|
and **the same vertical position for every line as in the editor pane**. In the prototype this is
|
||||||
|
achieved by scaling the type to the narrower column: `grid-template-columns: 35px 1fr`, font
|
||||||
|
`400 10.3px/20px` Plex Mono, gutter padding-right 9 px, text padding-right 11 px. The line-height
|
||||||
|
stays 20 px, so line *n* sits at the same y in both panes.
|
||||||
|
**In the real implementation, do it properly:** render the original text into the same layout the
|
||||||
|
editor uses and synchronise scroll offsets, so line *n* of the original is always level with line
|
||||||
|
*n* of the current file. The rule to satisfy: *the changed line and its previous version are on the
|
||||||
|
same horizontal line of the screen*.
|
||||||
|
- **The old line inside the panel:** line number `#C4741F` on `rgba(196,116,31,.14)` with a 2 px
|
||||||
|
`#C4741F` left border; text cell same tint, text `#E4E7E6`. So: **teal = what is there now, amber
|
||||||
|
= what was there before.** Never green/red.
|
||||||
|
- The editor keeps its own scroll while the overlay is open; the overlay follows it.
|
||||||
|
|
||||||
|
### Other states
|
||||||
|
|
||||||
|
- Hover on any list row (source control, explorer, menus): fill deepens one step
|
||||||
|
(`#18202B` → `#232C39`). No lift, no shadow, no opacity change, no scale on press.
|
||||||
|
- Focus on a text field: 2 px `#E8913A` border + 2 px `rgba(232,145,58,.22)` ring.
|
||||||
|
- Disabled: 40 % opacity, `cursor: not-allowed`.
|
||||||
|
|
||||||
|
### Animation
|
||||||
|
|
||||||
|
Only two things move:
|
||||||
|
1. **The blinking period / caret** — `@keyframes blink { 0%,49% { opacity:1 } 50%,100% { opacity:0 } }`,
|
||||||
|
`1.1s steps(1, end) infinite`. Used on the wordmark period, the prompt caret and the terminal
|
||||||
|
cursor. Freeze under `prefers-reduced-motion`.
|
||||||
|
2. **Colour transitions** — 160 ms `cubic-bezier(0.22, 1, 0.36, 1)`; 120 ms on small controls.
|
||||||
|
|
||||||
|
No entrance animation, no slide, no fade for the overlay — it is there or it is not. No scroll
|
||||||
|
reveal, no parallax.
|
||||||
|
|
||||||
|
### Responsive behaviour
|
||||||
|
|
||||||
|
Source control (300) and explorer (288) and the agent column (480) are fixed; the editor takes the
|
||||||
|
rest. Below roughly 1400 px the design has not been specified — ask before inventing collapse
|
||||||
|
behaviour. The hover panel is always 496 px and always anchored right.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
| State | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `activeView` | `'actual' \| 'original' \| 'preview' \| 'diff'` | Default `'actual'`. Persist per file. |
|
||||||
|
| `hoveredChangedLine` | `lineNumber \| null` | Drives the overlay. Cleared on mouse-out and on scroll-end if the pointer leaves. |
|
||||||
|
| `changedLines` | `Map<lineNumber, { current: string, previous: string, staged: boolean }>` | From the VCS diff of working tree vs HEAD/index. |
|
||||||
|
| `originalText` | `string` | Full HEAD/index version of the open file, needed for the overlay. |
|
||||||
|
| `editorScrollTop` | `number` | Mirrored into the overlay so the two panes stay in register. |
|
||||||
|
| `selectedFile`, `stagedFiles`, `unstagedFiles` | — | Source control and explorer content. |
|
||||||
|
|
||||||
|
Data needed: the file's working-tree content, its original content, and a line-level diff mapping
|
||||||
|
current line numbers to previous content. The overlay must be able to render the original file
|
||||||
|
immediately — pre-fetch it when a modified file is opened, not on hover.
|
||||||
|
|
||||||
|
## Design tokens
|
||||||
|
|
||||||
|
**Surfaces** `#101720` base · `#18202B` raised · `#232C39` selected/hover · `#3A424C` rule-strong
|
||||||
|
**Text** `#F4F5F4` strong · `#E4E7E6` body-strong · `#BAC0C0` body · `#828D9A` dim · `#6C7783` muted ·
|
||||||
|
`#5A6472` line numbers
|
||||||
|
**Accent** `#E8913A` amber · `#C4741F` amber-deep (previous state) · `#F0B476` amber-soft (inline code)
|
||||||
|
**Change** `#8FBFB4` current/changed, tint `rgba(143,191,180,.12)` · `#C4741F` previous, tint
|
||||||
|
`rgba(196,116,31,.14)`
|
||||||
|
**Syntax / chips** `#A8D6CB` string · `#F8C793` number-keyword · `#C3A6CE` structural · `#D8BEE4`
|
||||||
|
directive · `#EFBCB0` selector · `#D4D9D8` property
|
||||||
|
**Radius** 0 default · 2 px controls, chips, panels · 4 px window
|
||||||
|
**Borders** 1 px `#232C39` hairline · 2 px accent, used only for the selection marker, the changed-line
|
||||||
|
rule and a focused field
|
||||||
|
**Shadow** dropdown `0 4px 16px rgba(23,28,34,.10)`; modal `0 16px 48px rgba(23,28,34,.18)`. Nothing else
|
||||||
|
casts a shadow.
|
||||||
|
**Row heights** 22 px meta · 24 px explorer · 26 px source-control file · 28 px status bar · 30 px menu ·
|
||||||
|
34/38 px fields · 44 px title bar · 46 px panel headers
|
||||||
|
**Type** IBM Plex Mono 400/500/600/700 — code, labels, all numbers; IBM Plex Sans 400/500/600 — UI
|
||||||
|
text and prose. Code 13 px / 20 px. Labels 10 px, tracking `.14em`, uppercase. UI text 12–13 px.
|
||||||
|
**Do not use** green or red for change, gradients, blur, emoji, white or pure black.
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
None. No images, icons or fonts ship with this handoff. Both faces load from Google Fonts (IBM Plex
|
||||||
|
Mono, IBM Plex Sans) — swap in the licensed binaries in the app. Every glyph in the design is a
|
||||||
|
Unicode mark set in Plex Mono (`›` `⌄` `▤` `↑` `⑂` `·` `→` `▸▸` `✳` `└`); if the app already uses
|
||||||
|
Lucide at stroke width 1.75, those are the sanctioned replacements for the folder and arrow marks.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `03b-in-pane-diff.html` — the screen, standalone. Hover the teal line 31
|
||||||
|
(`- The scheduler fires it. Job \`mail-checker-ronde\`, every hour, on the hour.`) to see the
|
||||||
|
original overlay.
|
||||||
|
- Source of truth in the design project: `Helder IDE.dc.html`, section **[ 03 ] Diff**, screen
|
||||||
|
**3b**. Section 3a in the same file shows the older full-screen side-by-side diff, kept for
|
||||||
|
reference; 3b replaces it for in-pane use.
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
# Handoff: Helder — AI Code Workbench (Electron)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
**Helder** is a desktop code workbench for a developer who reviews and works with code written by an AI agent. It is a dense, four-column IDE-style window optimized for an ultrawide monitor (designed at **3440×1440**, but fully fluid down to ~1280px wide). It is **dark mode only** — there is intentionally no light theme and no theme toggle.
|
|
||||||
|
|
||||||
The core jobs the app supports:
|
|
||||||
- Fast project navigation + a single search that covers **both file contents and file names**.
|
|
||||||
- A Git review surface (commit box, staged list, changes list, per-file diff with four view modes).
|
|
||||||
- An editor with tabs and syntax-colored code.
|
|
||||||
- A live **Claude Code agent** terminal plus a normal shell terminal.
|
|
||||||
- A right-click **"Copy reference"** / **"Pass on to Agent"** flow that pushes `path:line` references into the agent's input so the developer can quickly point the agent at code.
|
|
||||||
|
|
||||||
## About the Design Files
|
|
||||||
The files in `design/` are a **design reference created in HTML/CSS/React-via-Babel** — a working prototype that shows the intended look, layout, and behavior. **They are not the production codebase and should not be shipped as-is.**
|
|
||||||
|
|
||||||
The task is to **recreate this design as a real Electron application**, using a proper build setup and the patterns below. The prototype loads React 18 + Babel from a CDN and stores everything in mock data; the real app should use a normal toolchain (see "Recommended Electron Stack"). Treat the HTML as the source of truth for *visual + interaction design*, and this README as the source of truth for *structure, tokens, and behavior*.
|
|
||||||
|
|
||||||
You can open `design/Helder - AI Code Workbench.html` directly in a browser to see and click the live prototype while building.
|
|
||||||
|
|
||||||
## Fidelity
|
|
||||||
**High-fidelity (hifi).** Colors, typography, spacing, diff coloring, and interactions are final. Recreate the UI pixel-faithfully. All exact values are in the Design Tokens section and in `design/styles.css` (the prototype's `:root` block is the canonical token list).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Electron Stack
|
|
||||||
No target codebase exists yet, so choose a modern, conventional setup:
|
|
||||||
- **Electron** (latest stable) with a **main** process and a **renderer**.
|
|
||||||
- **Renderer:** React 18 + TypeScript + Vite (`electron-vite` is a good scaffold). The prototype is already React, so component structure ports directly.
|
|
||||||
- **Syntax highlighting:** the prototype uses **Prism 1.29** (`prism-core` + `markup-templating`, `php`, `python`, `typescript`, `json`, `bash`, `markdown`). Keep Prism, or swap to **Shiki**/**CodeMirror 6** if you prefer; the token→color mapping is documented below. **Important Prism gotcha:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
|
||||||
- **Fonts:** UI = system stack (`-apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`); code/mono = **JetBrains Mono** (bundle the font locally for offline use — do not rely on Google Fonts CDN in Electron).
|
|
||||||
- **Real integrations to wire up (replacing the mock):**
|
|
||||||
- File tree + file contents → real FS via the main process (`fs`, `chokidar` for watching). Never touch FS directly from the renderer; use IPC / a preload bridge with `contextIsolation: true`.
|
|
||||||
- Git panel → shell out to `git` (or `simple-git`) for `status --porcelain`, staged/unstaged sets, `add`/`reset`, `commit`, and `diff`. The prototype computes diffs in JS with an LCS; in the real app prefer `git diff` output, but the four view modes still derive from an original/updated text pair per file.
|
|
||||||
- Terminals → real PTYs via **node-pty** + **xterm.js**. The "Claude agent" pane is just a terminal that runs the `claude` CLI; the bottom pane is a normal shell. The prototype fakes both — see "Terminals".
|
|
||||||
- Search → ripgrep (`rg`) for content search; a fast fuzzy matcher (e.g. `fzf`-style or `fuse.js`) for file-name search.
|
|
||||||
- "Copy reference" → Electron `clipboard.writeText`.
|
|
||||||
- "Pass on to Agent" → write into the agent terminal's PTY using **bracketed paste** (`\x1b[200~` … `\x1b[201~`) so the `claude` CLI treats it as pasted input and does NOT submit it. This is the real mechanism the prototype only simulates.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global Layout
|
|
||||||
|
|
||||||
Top-level vertical stack (`.app`, `height:100vh`, `display:flex; flex-direction:column`):
|
|
||||||
|
|
||||||
1. **Title bar** — `height: 36px`, fixed.
|
|
||||||
2. **Workbench** — `flex: 1`, a horizontal flex row of four columns separated by draggable splitters.
|
|
||||||
3. **Status bar** — `height: 23px`, fixed.
|
|
||||||
|
|
||||||
**Design principle the client asked for:** keep the chrome minimal — title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height so code and tools own the screen.
|
|
||||||
|
|
||||||
### Workbench columns (left → right)
|
|
||||||
All columns are **horizontally resizable** by dragging the 5px splitter between them. The editor is the flex-grow column; the other three have explicit pixel widths with min/max clamps.
|
|
||||||
|
|
||||||
| # | Column | Default width | Min–Max | Notes |
|
|
||||||
|---|--------|---------------|---------|-------|
|
|
||||||
| 1 | **Source Control** (Git) | 232px | 160–460 | commit box + staged list + changes list |
|
|
||||||
| 2 | **Explorer** (file tree) | 244px | 160–520 | VS Code-style tree, colored type icons |
|
|
||||||
| 3 | **Editor** | flex:1 | min 240px | tabs + code/diff |
|
|
||||||
| 4 | **Right column** | 444px | 280–780 | split vertically: agent terminal (top) + shell (bottom), draggable horizontal splitter, default top fraction 0.52 |
|
|
||||||
|
|
||||||
Splitter: 5px hit area, transparent; inner 1px line is `--border`, turning to `--accent` (0.55 alpha) on hover/drag. Vertical splitter cursor `col-resize`; horizontal `row-resize`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Screens / Components
|
|
||||||
|
|
||||||
### 1. Title bar (`.titlebar`)
|
|
||||||
- Height 36px, background `--bg-3`, bottom border `--border`, horizontal padding 12px, items gap 14px.
|
|
||||||
- Left → right:
|
|
||||||
- **Traffic lights**: three 12px circles, gap 8px — red `#e0696a`, yellow `#d8a85c`, green `#5cbd6b`. (On macOS use the native frame instead; these are decorative in the web prototype.)
|
|
||||||
- **Wordmark**: a small spark/diamond icon in `--accent`, then **`Helder`** (weight 600, `--fg-0`), an em-dash in `--fg-3`, then the project name (`console`) in `--fg-2`. Font 12px.
|
|
||||||
- **Breadcrumb** of the active file path, monospace 11.5px, `--fg-3`, segments joined by ` › `; last segment `--fg-1`.
|
|
||||||
- Spacer (flex:1).
|
|
||||||
- **Search button**: `.tb-btn` — ghost button, 11.5px, `--fg-2`; search icon + "Search" + a `<kbd>⌘F</kbd>` chip. Hover → `--hover` bg, `--fg-0` text. (There is intentionally **no "Go to File" button** — search covers file names too.)
|
|
||||||
|
|
||||||
### 2. Source Control panel (`.col` #1)
|
|
||||||
Order, top to bottom:
|
|
||||||
|
|
||||||
**a. Panel header** (`.phead`, height 30px): branch icon + "SOURCE CONTROL" (uppercase, 10.5px, letter-spacing .09em, `--fg-2`) + a count pill on the right showing the number of changed (uncommitted) files.
|
|
||||||
|
|
||||||
**b. Commit box** (`.commit-box`, padding 9px 10px, bottom border): a flex row, `align-items:flex-start`, gap 7px.
|
|
||||||
- **Message field**: a 1-row auto-growing `<textarea>` (`.commit-input`), flex:1, bg `--bg-0`, 1px `--border-2` border, radius 7px, padding 7px 9px, font UI 12px, min-height 32px; focus border `--accent-line`. Placeholder: `Message (⌘↵ to commit)`.
|
|
||||||
- **Commit button** (`.commit-btn`): solid `--accent` bg, text `#0c1320`, weight 600, radius 7px, height 32px, padding 0 11px; a check icon + label `Commit` (or `Commit N` when N files are staged). **Disabled** (bg `--bg-3`, text `--fg-3`, `not-allowed`) unless there is ≥1 staged file AND a non-empty message. `⌘↵`/`Ctrl+↵` in the field commits.
|
|
||||||
|
|
||||||
**c. Two stacked lists** (`.git-body`, scrolls). Both lists are **always present** even when empty:
|
|
||||||
- **Staged Changes** group header (`.git-group`) with count; on hover shows an "unstage all" `−` button on the right. Below it, the staged file rows — or, if empty, the hint `Nothing staged — use + to stage a file` (`.git-none`).
|
|
||||||
- A 1px **divider** (`.git-divider`, `--border`, margin 8px 12px 2px).
|
|
||||||
- **Changes** group header with count; hover shows a "stage all" `+` button. Below it, the unstaged rows — or hint `All changes staged`.
|
|
||||||
- If the working tree is fully clean (everything committed), show a centered empty state: a green check icon + `No changes — working tree clean`.
|
|
||||||
|
|
||||||
**Git file row** (`.git-row`, padding 3px 12px 3px 14px, gap 8px, `cursor:pointer`):
|
|
||||||
- **Status letter** (`.git-stat`, mono 11px bold, 13px wide): `M` = `--mod` (amber), `A` = `--add` (green), `D` = `--del` (red), `R` = `--ren` (blue).
|
|
||||||
- **File-type icon** (see Explorer).
|
|
||||||
- **File name** (`--fg-1`; deleted files get `line-through` + `--fg-3`).
|
|
||||||
- **Directory** (`.git-dir`): the parent path, `--fg-3`, 11px, right-aligned with `direction:rtl` ellipsis, max-width ~42%.
|
|
||||||
- **Stage/unstage button** (`.git-act`): appears on row hover, 20×20, a `+` (stage) on Changes rows or `−` (unstage) on Staged rows.
|
|
||||||
- **Delta** (`.git-delta`, mono 10.5px): `+N` in `--add`, `-N` in `--del`.
|
|
||||||
- Hover bg `--hover`; active (open in editor) bg `--sel` + 2px `--accent` left rail.
|
|
||||||
- **Click** opens the file's diff in the editor. **Right-click** → context menu (see Interactions).
|
|
||||||
|
|
||||||
**d. Footer** (`.git-foot`): branch icon + branch name (`--fg-0` bold) on the left; total `+adds`/`-dels` (mono) on the right.
|
|
||||||
|
|
||||||
> Seed/demo data: branch `feat/payments-balance`; 6 changed files; 2 pre-staged (`PaymentService.php` [A], `config/app.json` [M]); 4 unstaged including a deleted `LegacyUser.php` [D].
|
|
||||||
|
|
||||||
### 3. Explorer / file tree (`.col` #2)
|
|
||||||
- Header `.phead`: "EXPLORER" + project name (`console`) right-aligned, mono 10.5px `--fg-3`.
|
|
||||||
- Tree body scrolls. Rows are 23px tall.
|
|
||||||
- **Folder row** (`.tree-row.folder`): a rotating chevron (▸ closed → ▾ open, CSS rotate 90°, .12s) + a simple folder glyph + name (`--fg-1`). Click toggles expand. Indent = `10 + depth*13` px.
|
|
||||||
- **File row**: file-type icon + name. Active file → `--sel` bg + 2px `--accent` left rail. Files with an uncommitted change show a status letter badge (`M`/`A`/`D`) on the right (cleared once that file is committed). Deleted files render struck-through.
|
|
||||||
- **File-type icon** (`.ficon`): a **15×15 rounded-square monogram chip** filled with a per-type color, containing 1–3 dark glyph characters (`#0c0d0f`, mono 7.5px bold). Do **not** use brand logos. Colors/labels:
|
|
||||||
- `.php` → `#a78bdb` "php" · `.js/.mjs` → `#e6c860` "js" · `.ts/.tsx` → `#5a9bd6` "ts" · `.py` → `#5fa8d6` "py" · `.html` → `#e08b6a` "<>" · `.css` → `#5a9bd6` "{}" · `.json` → `#d8a85c` "{}" · `.md` → `#9aa0a8` "md" · `.env/.sh` → `#7fc6a0` "$" · `.yml/.yaml` → `#cf7a6a` "yml".
|
|
||||||
- Special names: `composer.json` `#a78bdb` "co"; `package.json` `#cf7a6a` "pk"; `README.md` `#5a9bd6` "md"; `.env` `#7fc6a0` "$".
|
|
||||||
- Default/unknown: `#7d838c`, first two letters of the name.
|
|
||||||
|
|
||||||
### 4. Editor (`.col` #3)
|
|
||||||
**Tab strip** (`.tabs`, height 35px, bg `--bg-3`, horizontal scroll, no scrollbar):
|
|
||||||
- **Tab** (`.tab`): file-type icon + name, 12.5px, max-width 230px. Inactive `--bg-3`/`--fg-2`; active `--bg-0`/`--fg-0` with a 2px `--accent` top rail. A small mode badge (`.tab-mode`: `orig`/`upd`/`diff`/`split`) shows for changed files. A close `×` (`.tclose`, 17px) on the right; **middle-click** also closes. Hover bg `#272b31`.
|
|
||||||
|
|
||||||
**Diff toolbar** (`.diff-bar`, only shown for changed files; height 26px, bg `--bg-3`):
|
|
||||||
- Left: a status word (`Modified`/`Added`/`Deleted`) tinted by status, then `+N` (green) / `−N` (red).
|
|
||||||
- Right: a **4-segment control** (`.seg`, 1px `--border-2`, radius 7px): **Original · Updated · Diff · [⊟ Split]**. Active segment bg `--accent-soft`, text `--fg-0`. The Split segment carries a small split-pane icon.
|
|
||||||
|
|
||||||
**Code area** (`.editor`, mono 13px, line-height 20px, scrolls). Each line is a flex row (`.ln-row`):
|
|
||||||
- **Gutter** (`.ln-gutter`, 54px, right-aligned, `--fg-3`, clickable to select that line; shift-click extends a range).
|
|
||||||
- (Diff mode only) a **sign column** (`.ln-sign`, 14px): `+`/`-`.
|
|
||||||
- **Code** (`.ln-code`, `white-space:pre`), syntax-highlighted.
|
|
||||||
- Cursor line gets a faint `rgba(255,255,255,.035)` bg; selected range gets `--accent-soft`.
|
|
||||||
|
|
||||||
**The four view modes** (all are presentations of the same per-file diff; same color language everywhere → **red = removed / changed-from, green = added / changed-to**; syntax highlighting stays on in all four):
|
|
||||||
1. **Original** — the pre-edit file, read-only. Removed/changed lines get a **red gutter bar** (`box-shadow: inset 2px 0 0 var(--del)` on the row). No inline +/- markers.
|
|
||||||
2. **Updated** — the current/live file. Added/changed lines get a **green gutter bar** (`inset 2px 0 0 var(--add)`). (In the real app this is the editable buffer.)
|
|
||||||
3. **Diff** — single-pane unified inline diff: removed lines red with `-`, added lines green with `+`, full file context in sequence. This is the default when a changed file is opened.
|
|
||||||
4. **Split** — clicking it opens a **full-screen overlay** (`.split-overlay`, `position:fixed; inset:0; z-index:60`) covering all panels: header bar (file icon + path + status + `+/−`) with a **Collapse** button + `Esc` to exit; body is two side-by-side panes — **Original** left, **Updated** right — with lines aligned (blank padded rows where one side has no counterpart, rendered with a faint diagonal hatch `.ln-row.empty`), red bars on the left, green bars on the right, and **synced vertical scrolling** between panes.
|
|
||||||
|
|
||||||
Empty editor state (`.empty-ed`): file icon + "No file open" + a key-hint list (`Search files & content ⌘F`, `Copy reference right-click`, `Pass on to Agent right-click`).
|
|
||||||
|
|
||||||
### 5. Right column — terminals (`.col` #4)
|
|
||||||
Two stacked terminal panes split by a draggable horizontal splitter (top fraction default 0.52).
|
|
||||||
|
|
||||||
Each pane:
|
|
||||||
- **Header** (`.term-head`, height 28px, bg `--bg-3`): a status dot (the agent pane's dot is green + pulsing when a session is live), a mono label (`claude` / `zsh`), and a right-aligned tag (`agent session` / `— bash · ~/console`).
|
|
||||||
- **Body** (`.term-body`, mono 12.5px, line-height 18px, scrolls, `cursor:text`): a stream of output lines plus an input row at the bottom.
|
|
||||||
- **Input row** (`.term-input`): a colored prompt glyph (agent `>` in violet `#c98bdb`; shell `console %` with `%` in `--fg-3`) + a transparent auto-growing **`<textarea>`** (`.term-ta`, mono 12.5px, `caret-color:--accent`, up to 8 rows). **Enter** submits; **Shift+Enter** inserts a newline; ↑/↓ recall history (only when the buffer has no newline).
|
|
||||||
|
|
||||||
**Agent pane behavior (top):** it is a terminal. Typing `claude` "boots" an agent session (a bordered welcome card + intro line). In a live session, submitting a prompt streams a short simulated response: a "thinking…" line, `read`/`grep` tool lines, an **edit card** (`.term-card` — a bordered block with a header `edit · <path>` and red `-`/green `+` rows), then a green "Done" line. `/exit` ends the session. **In the real app, replace all of this with a real PTY running the `claude` CLI** (node-pty + xterm.js). The simulated content (`agentSeed`, `runAgent`, `bootAgent` in `design/src/terminals.jsx`) is only there to show the intended visual style — keep that styling, drop the fakery.
|
|
||||||
|
|
||||||
**Shell pane (bottom):** a normal terminal. The prototype fakes `ls`, `pwd`, `cat`, `git status`, `git diff`, `echo`, `clear`; replace with a real shell PTY.
|
|
||||||
|
|
||||||
### 6. Status bar (`.statusbar`, height 23px, bg `--bg-3`, 11px)
|
|
||||||
Segments left→right: an **accent-colored** branch chip (bg `--accent`, text `#0c1320`); `+adds / −dels`; spacer; cursor position (`Ln X, Col Y`, or `N lines selected`); `Spaces: 4`; `UTF-8`; active file language (bold); and the current view mode word (`Original`/`Updated`/`Diff`/`Split`) when a changed file is active. Each segment hover bg `--hover`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interactions & Behavior
|
|
||||||
|
|
||||||
### Search (⌘F) — `SearchModal`
|
|
||||||
- Opened by `⌘F` / `Ctrl+F` or the title-bar Search button. (There is no separate "go to file" command; this one modal does both.)
|
|
||||||
- A scrim (`.scrim`, `rgba(8,9,11,.5)`, slight blur) with a **large centered modal** (`.search-modal`, width 940px, max 94vw).
|
|
||||||
- Top: a single text input (search icon + field, placeholder `Search content and file names…`) and a right-aligned count chip (`N hits · M files`).
|
|
||||||
- Body is **two columns**:
|
|
||||||
- **Left — Content** (`.sc-left`, flex-grow, the wide pane, max-height 460px, scrolls): substring matches across all file contents (case-insensitive, min 2 chars), grouped by file. Each group: a file header (icon + path + hit count); then up to 12 matching lines, each `line# + the line text with the matched term wrapped in <mark>` (`mark` bg `rgba(216,168,92,.28)`). Clicking a line opens that file at that line.
|
|
||||||
- **Right — Files** (`.sc-right`, **fixed 256px — deliberately narrower**, max-height 460px, scrolls, slightly darker bg): fuzzy matches on file name (then path), each a 2-line cell (name with matched chars bolded in `--accent` + dimmed dir path). A `•` dot marks files with uncommitted changes. Clicking opens the file.
|
|
||||||
- Section headers (`.sc-head`) are sticky, uppercase 10px, with a count pill.
|
|
||||||
- **Keyboard:** ↑/↓ move a selection through the flat list of content hits (selected line highlighted `--accent-soft`); **Enter** opens the selected content hit (or the first file if there are no content hits); **Esc** closes. Clicking the scrim closes.
|
|
||||||
|
|
||||||
### Right-click in the editor → context menu (`.ctx`) — exactly two items
|
|
||||||
1. **Copy reference** — copies a **project-relative** reference to the clipboard:
|
|
||||||
- No selection: `path:line` (e.g. `src/Http/Controller/UserController.php:42`).
|
|
||||||
- With a selection: `path:start-end` (e.g. `src/Http/Controller/UserController.php:42-58`).
|
|
||||||
- The clicked line is resolved via `document.caretRangeFromPoint`; a multi-line text selection (or a gutter range-select) yields the start-end form.
|
|
||||||
2. **Pass on to Agent** — opens a small **inline popup** (`.pass-pop`) anchored at the click:
|
|
||||||
- A header (`✦ Pass on to Agent` + `esc` chip), one optional single-line text field (placeholder `Add a note (optional)…`), an `INSERTS` preview showing the composed line, and a footer `↵ insert into agent · esc cancel`.
|
|
||||||
- On **Enter**: compose ONE line = `<typed note> <reference>` (just the bare reference if the field is empty) and insert it into the **agent terminal's input** as a **new, unsubmitted line, leaving the caret on a fresh line below** — so the action can be repeated to **stack several references** before the developer presses Enter to send. **Insert never submits.** In the real app, do this by writing the text to the agent PTY wrapped in **bracketed paste** (`\x1b[200~ … \x1b[201~`).
|
|
||||||
- **Esc** cancels with no insert; clicking outside cancels.
|
|
||||||
- The context menu shows the resolved reference as a muted note at the top.
|
|
||||||
- The menu reuses a generic popup also used by tree/git rows; **only the editor menu is the two-item Copy/Pass menu.** Tree rows offer Copy reference / Send reference to agent / Copy file name / Open / Reveal; Git rows additionally offer **Stage changes** or **Unstage changes** and **Open diff**.
|
|
||||||
|
|
||||||
### Diff mode switching
|
|
||||||
- Clicking Original/Updated/Diff sets the active tab's mode and closes any open Split overlay. Clicking Split opens the full-screen overlay; the previous mode is preserved for when you collapse. `Esc` collapses Split.
|
|
||||||
- Opening a file from the Git panel defaults to **Diff**; opening from search/tree defaults to the plain file (**Updated** for changed files so line numbers map to the current content).
|
|
||||||
|
|
||||||
### Staging / commit
|
|
||||||
- Hover a git row → `+`/`−` to stage/unstage; group headers have stage-all/unstage-all. Right-click → Stage/Unstage.
|
|
||||||
- Commit (button or `⌘↵`) moves staged files out of the working set, clears the message, shows a toast `Committed N file(s)`, and clears those files' badges in the tree.
|
|
||||||
|
|
||||||
### Toasts (`.toast-wrap`, bottom-center)
|
|
||||||
- Small dark pill with an icon, a title, and (optionally) a mono reference chip in `--accent`. Auto-dismiss after ~2.3s. Used for "Copied reference", "Passed to agent", "Committed N files".
|
|
||||||
|
|
||||||
### Keyboard shortcuts
|
|
||||||
- `⌘F` / `Ctrl+F` — open Search.
|
|
||||||
- `⌘W` / `Ctrl+W` — close active tab.
|
|
||||||
- `Esc` — collapse Split if open, else close any overlay/menu.
|
|
||||||
- `⌘↵` in commit box — commit.
|
|
||||||
- In terminals: `Enter` submit, `Shift+Enter` newline, `↑/↓` history.
|
|
||||||
|
|
||||||
### Animations
|
|
||||||
- Chevron rotate .12s. Splitter line color .12s. Overlays/toasts a ~.12s ease-out entrance (translateY 6–8px). Live agent dot: a 2.2s pulsing box-shadow ring. Keep motion subtle; respect `prefers-reduced-motion`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
Renderer state (the prototype keeps all of this in the top `App` component; in the real app, lift FS/git/terminal state into the main process and stream via IPC):
|
|
||||||
- `openTabs: string[]` (file paths), `activeTab`, `tabMode: Record<path, 'original'|'updated'|'diff'|'code'>`.
|
|
||||||
- `splitFor: path | null` (which file is in full-screen Split).
|
|
||||||
- `openDirs: Set<path>` (expanded tree folders).
|
|
||||||
- `cursor: {path, line, col}`, `selection: {path, start, end} | null`.
|
|
||||||
- `overlay: 'search' | null`, `menu` (context menu descriptor), `passPopup: {x,y,ref} | null`, `toasts[]`.
|
|
||||||
- Git: `staged: Set<path>`, `committed: Set<path>`, `commitMsg`.
|
|
||||||
- Column sizes: `gitW`, `treeW`, `rightW` (px) + the right column's top-pane fraction.
|
|
||||||
- Per changed file the app needs an **original text** and **updated text**; the diff (`rows` for unified, `left`/`right` for Original/Updated with per-line add/del marks, and `split` aligned pairs, plus `+`/`−` counts) is derived from those. See `buildDiff()` in `design/src/data.js` for the exact derivation (LCS line diff) — in production prefer real `git diff` but keep the same four derived views.
|
|
||||||
|
|
||||||
## Data the real app must supply (replacing `design/src/data.js`)
|
|
||||||
- Project file tree (nested dirs/files) from the opened folder.
|
|
||||||
- File contents on demand.
|
|
||||||
- Git: current branch, changed files with status (`A/M/D/R`) and `+/−` counts, staged/unstaged sets, and original+updated text per changed file.
|
|
||||||
- Search: content matches (ripgrep) and file-name matches (fuzzy).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Tokens
|
|
||||||
Canonical source: the `:root` block in `design/styles.css`. Key values:
|
|
||||||
|
|
||||||
**Surfaces / neutrals (cool charcoal):**
|
|
||||||
- `--bg-0 #16171a` (editor) · `--bg-1 #1a1c1f` (terminals) · `--bg-2 #1f2226` (sidebars) · `--bg-3 #23262b` (headers/tabs)
|
|
||||||
- `--hover #2a2e34` · `--active #313742` · `--sel #2b323d`
|
|
||||||
- `--border #2a2d33` · `--border-2 #34383f`
|
|
||||||
- Text: `--fg-0 #e6e8ea` · `--fg-1 #b4bac2` · `--fg-2 #838a94` · `--fg-3 #5d636c`
|
|
||||||
|
|
||||||
**Accent (single, cool blue):**
|
|
||||||
- `--accent #4d8dff` · `--accent-soft rgba(77,141,255,.16)` · `--accent-line rgba(77,141,255,.55)`
|
|
||||||
|
|
||||||
**Git / diff status:**
|
|
||||||
- `--add #5cbd6b` · `--del #e0696a` · `--mod #d8a85c` · `--ren #5aa6d6`
|
|
||||||
- `--add-bg rgba(92,189,107,.10)` · `--del-bg rgba(224,105,106,.10)`
|
|
||||||
|
|
||||||
**Syntax tokens (tuned to charcoal):**
|
|
||||||
- keyword `--t-key #c98bdb` · string `--t-str #94c980` · number `--t-num #e0a06a` · function `--t-fn #6aa6f0` · comment `--t-com #5f656e` (italic) · tag `--t-tag #7fc6a0` · attr-name `--t-attr #d8b15c` · punctuation `--t-punc #9aa0a8` · variable `--t-var #e6e8ea` · constant/boolean/builtin `--t-const #e08b6a` · property `--t-prop #6ec0c0`
|
|
||||||
- (See the `.ln-code .token.*` rules in `design/styles.css` for the full Prism token→variable mapping.)
|
|
||||||
|
|
||||||
**Typography:**
|
|
||||||
- UI: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`. Base 13px.
|
|
||||||
- Mono: `"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`. Editor 13px / line-height 20px; terminals 12.5px / 18px.
|
|
||||||
- Panel headers: 10–10.5px uppercase, letter-spacing ~.08em.
|
|
||||||
|
|
||||||
**Radii / spacing:** card/control radii 6–11px (panels 7px, modals 10–11px, chips 4–5px). Icon chips 15px / radius 3.5px. Splitters 5px. Column gaps are the splitters; inside panels use the paddings noted per component.
|
|
||||||
|
|
||||||
**Shadows:** menus/popovers `0 16px 44px rgba(0,0,0,.5)`; modals `0 24px 70px rgba(0,0,0,.55)`; toasts `0 12px 34px rgba(0,0,0,.45)`.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
- **No raster/brand assets.** All icons are simple inline SVGs (geometric: search, branch, close, copy, terminal, spark/diamond, file, folder, diff, plus, minus, check, discard, expand/collapse) — see the `Icon` map in `design/src/components.jsx`. File-type icons are colored monogram chips (no third-party logos). Recreate these as a small icon set (or substitute an open icon library like Lucide for the UI icons, keeping the monogram chips for file types).
|
|
||||||
- **Font:** JetBrains Mono (OFL) — bundle locally.
|
|
||||||
|
|
||||||
## Files in this bundle
|
|
||||||
- `design/Helder - AI Code Workbench.html` — entry point; open in a browser to view the live prototype.
|
|
||||||
- `design/styles.css` — all styling + the canonical design-token `:root` block.
|
|
||||||
- `design/src/data.js` — mock filesystem, git changes, before/after file pairs, and the `buildDiff()` LCS diff that powers the four view modes. **Replace with real FS/git data.**
|
|
||||||
- `design/src/highlight.js` — Prism language/ext mapping + monogram file-icon metadata.
|
|
||||||
- `design/src/components.jsx` — `Icon` set, `FileIcon`, `GitPanel`/`GitRow`, `FileTree`.
|
|
||||||
- `design/src/editor.jsx` — tabs, the four-mode editor (`PaneView`, `buildLines`), and the full-screen `SplitView`.
|
|
||||||
- `design/src/terminals.jsx` — the agent + shell terminal component and the simulated session (replace the simulation with real PTYs).
|
|
||||||
- `design/src/overlays.jsx` — `SearchModal`, `ContextMenu`, `PassPopup`, `Toasts`.
|
|
||||||
- `design/src/app.jsx` — composition: layout, resizable splitters, keyboard shortcuts, git/commit/stage logic, copy-reference + pass-to-agent wiring.
|
|
||||||
|
|
||||||
## Implementation order (suggested)
|
|
||||||
1. Electron shell + frameless dark window; port tokens to CSS variables; bundle JetBrains Mono.
|
|
||||||
2. Static layout: four resizable columns + title/status bars.
|
|
||||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
|
||||||
4. Git panel from `git status` (read-only), then staging + commit, then the four diff modes + Split.
|
|
||||||
5. Search (ripgrep + fuzzy).
|
|
||||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
|
||||||
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>Helder — AI Code Workbench</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
|
|
||||||
<!-- Prism (manual highlighting) -->
|
|
||||||
<script>window.Prism = window.Prism || {}; window.Prism.manual = true;</script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
|
||||||
|
|
||||||
<!-- React + Babel (pinned) -->
|
|
||||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
|
||||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
|
||||||
|
|
||||||
<!-- data + helpers (plain JS) -->
|
|
||||||
<script src="src/data.js"></script>
|
|
||||||
<script src="src/highlight.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
|
|
||||||
<!-- components (JSX) -->
|
|
||||||
<script type="text/babel" src="src/components.jsx"></script>
|
|
||||||
<script type="text/babel" src="src/editor.jsx"></script>
|
|
||||||
<script type="text/babel" src="src/terminals.jsx"></script>
|
|
||||||
<script type="text/babel" src="src/overlays.jsx"></script>
|
|
||||||
<script type="text/babel" src="src/app.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
|
|
||||||
|
|
||||||
function Splitter({ orientation = "v", onDelta }) {
|
|
||||||
const [drag, setDrag] = useState(false);
|
|
||||||
function down(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
let last = { x: e.clientX, y: e.clientY };
|
|
||||||
setDrag(true);
|
|
||||||
document.body.style.cursor = orientation === "v" ? "col-resize" : "row-resize";
|
|
||||||
document.body.style.userSelect = "none";
|
|
||||||
function mv(ev) {
|
|
||||||
onDelta(ev.clientX - last.x, ev.clientY - last.y);
|
|
||||||
last = { x: ev.clientX, y: ev.clientY };
|
|
||||||
}
|
|
||||||
function up() {
|
|
||||||
setDrag(false);
|
|
||||||
document.body.style.cursor = ""; document.body.style.userSelect = "";
|
|
||||||
document.removeEventListener("mousemove", mv); document.removeEventListener("mouseup", up);
|
|
||||||
}
|
|
||||||
document.addEventListener("mousemove", mv); document.addEventListener("mouseup", up);
|
|
||||||
}
|
|
||||||
return <div className={"splitter" + (orientation === "h" ? " h" : "") + (drag ? " drag" : "")} onMouseDown={down} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RightColumn({ width }) {
|
|
||||||
const [topFrac, setTopFrac] = useState(0.52);
|
|
||||||
const ref = useRef(null);
|
|
||||||
function delta(dx, dy) {
|
|
||||||
const h = ref.current ? ref.current.clientHeight : 600;
|
|
||||||
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)));
|
|
||||||
}
|
|
||||||
const agent = useMemo(() => agentSeed(), []);
|
|
||||||
const shell = useMemo(() => shellSeed(), []);
|
|
||||||
return (
|
|
||||||
<div className="col right-col" style={{ width, flex: "0 0 " + width + "px" }}>
|
|
||||||
<div ref={ref} style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
|
||||||
<div style={{ flex: "0 0 " + (topFrac * 100) + "%", minHeight: 0, display: "flex" }}>
|
|
||||||
<Terminal kind="agent" seed={agent} />
|
|
||||||
</div>
|
|
||||||
<Splitter orientation="h" onDelta={delta} />
|
|
||||||
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
|
||||||
<Terminal kind="shell" seed={shell} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
|
||||||
function ancestors(path) {
|
|
||||||
const parts = path.split("/"); const out = [];
|
|
||||||
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function initialOpenDirs(node, set) {
|
|
||||||
if (node.type === "dir") {
|
|
||||||
if (node.open && node.path) set.add(node.path);
|
|
||||||
(node.children || []).forEach((c) => initialOpenDirs(c, set));
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const changeMap = useMemo(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status])), []);
|
|
||||||
const changeSet = useMemo(() => new Set(PROJECT.changes.map((c) => c.path)), []);
|
|
||||||
|
|
||||||
const [tabs, setTabs] = useState([
|
|
||||||
{ path: "src/Http/Controller/UserController.php" },
|
|
||||||
{ path: "public/assets/app.js" },
|
|
||||||
{ path: "src/types/api.ts" },
|
|
||||||
]);
|
|
||||||
const [active, setActive] = useState("src/Http/Controller/UserController.php");
|
|
||||||
const [tabMode, setTabMode] = useState({ "src/Http/Controller/UserController.php": "diff" });
|
|
||||||
const [openDirs, setOpenDirs] = useState(() => initialOpenDirs(PROJECT.tree, new Set()));
|
|
||||||
const [cursor, setCursor] = useState({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 });
|
|
||||||
const [selection, setSelection] = useState(null);
|
|
||||||
const [overlay, setOverlay] = useState(null);
|
|
||||||
const [menu, setMenu] = useState(null);
|
|
||||||
const [toasts, setToasts] = useState([]);
|
|
||||||
const [splitFor, setSplitFor] = useState(null);
|
|
||||||
const [staged, setStaged] = useState(() => new Set(["src/Service/PaymentService.php", "config/app.json"]));
|
|
||||||
const [committed, setCommitted] = useState(() => new Set());
|
|
||||||
const [commitMsg, setCommitMsg] = useState("");
|
|
||||||
const [passPopup, setPassPopup] = useState(null);
|
|
||||||
|
|
||||||
const [gitW, setGitW] = useState(232);
|
|
||||||
const [treeW, setTreeW] = useState(244);
|
|
||||||
const [rightW, setRightW] = useState(444);
|
|
||||||
|
|
||||||
function toast(title, ref) {
|
|
||||||
const id = lid();
|
|
||||||
setToasts((t) => [...t, { id, title, ref }]);
|
|
||||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2300);
|
|
||||||
}
|
|
||||||
async function copyText(text, label) {
|
|
||||||
try { await navigator.clipboard.writeText(text); }
|
|
||||||
catch (e) {
|
|
||||||
const ta = document.createElement("textarea"); ta.value = text;
|
|
||||||
ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta);
|
|
||||||
ta.select(); try { document.execCommand("copy"); } catch (_) {} ta.remove();
|
|
||||||
}
|
|
||||||
toast(label || "Copied reference", text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleDir = useCallback((p) => {
|
|
||||||
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n; });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function reveal(path) {
|
|
||||||
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n; });
|
|
||||||
}
|
|
||||||
|
|
||||||
const stage = (p) => setStaged((s) => { const n = new Set(s); n.add(p); return n; });
|
|
||||||
const unstage = (p) => setStaged((s) => { const n = new Set(s); n.delete(p); return n; });
|
|
||||||
const stageAll = () => setStaged(new Set(PROJECT.changes.filter((c) => !committed.has(c.path)).map((c) => c.path)));
|
|
||||||
const unstageAll = () => setStaged(new Set());
|
|
||||||
function commit() {
|
|
||||||
const list = PROJECT.changes.filter((c) => staged.has(c.path) && !committed.has(c.path));
|
|
||||||
if (!list.length || !commitMsg.trim()) return;
|
|
||||||
setCommitted((prev) => { const n = new Set(prev); list.forEach((c) => n.add(c.path)); return n; });
|
|
||||||
setStaged(new Set());
|
|
||||||
const msg = commitMsg.trim();
|
|
||||||
setCommitMsg("");
|
|
||||||
toast(`Committed ${list.length} file${list.length > 1 ? "s" : ""}`, msg.length > 34 ? msg.slice(0, 34) + "…" : msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFile(path, opts = {}) {
|
|
||||||
const changed = !!PROJECT.diffs[path];
|
|
||||||
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }]);
|
|
||||||
setActive(path);
|
|
||||||
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? "diff" : (m[path] || (changed ? "diff" : "code")) }));
|
|
||||||
reveal(path);
|
|
||||||
if (opts.line) {
|
|
||||||
// show the current/updated file so line numbers map to search hits
|
|
||||||
setSplitFor(null);
|
|
||||||
setTabMode((m) => ({ ...m, [path]: changed ? "updated" : "code" }));
|
|
||||||
setCursor({ path, line: opts.line, col: 1 });
|
|
||||||
setSelection(null);
|
|
||||||
setTimeout(() => {
|
|
||||||
const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]');
|
|
||||||
if (row) { const ed = row.closest(".editor"); const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2; }
|
|
||||||
}, 70);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTab(path) {
|
|
||||||
setTabs((t) => {
|
|
||||||
const ix = t.findIndex((x) => x.path === path);
|
|
||||||
const next = t.filter((x) => x.path !== path);
|
|
||||||
if (path === active) {
|
|
||||||
const fallback = next[ix] || next[ix - 1] || next[next.length - 1];
|
|
||||||
setActive(fallback ? fallback.path : null);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- context menus ----
|
|
||||||
function openMenu(e, target) {
|
|
||||||
e.preventDefault(); e.stopPropagation();
|
|
||||||
const sparkSend = (ref) => ({ icon: Icon.spark({}), label: "Send reference to agent", onClick: () => { window.dispatchEvent(new CustomEvent("agentPaste", { detail: ref })); toast("Passed to agent", ref); } });
|
|
||||||
if (target.kind === "editor") {
|
|
||||||
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}`;
|
|
||||||
const mx = e.clientX, my = e.clientY;
|
|
||||||
setMenu({
|
|
||||||
x: mx, y: my, note: ref,
|
|
||||||
items: [
|
|
||||||
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
|
|
||||||
{ icon: Icon.spark({}), label: "Pass on to Agent", onClick: () => setPassPopup({ x: mx, y: my, ref }) },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const isDir = target.kind === "dir";
|
|
||||||
const ref = isDir ? target.path + "/" : target.path;
|
|
||||||
const name = target.path.split("/").pop();
|
|
||||||
const items = [
|
|
||||||
{ primary: true, icon: Icon.copy({}), label: "Copy reference", onClick: () => copyText(ref) },
|
|
||||||
sparkSend(ref),
|
|
||||||
{ icon: Icon.copy({}), label: isDir ? "Copy folder path" : "Copy file name", onClick: () => copyText(isDir ? target.path : name, "Copied") },
|
|
||||||
];
|
|
||||||
if (!isDir) {
|
|
||||||
items.push({ sep: true });
|
|
||||||
if (target.kind === "git") {
|
|
||||||
const isStaged = staged.has(target.path);
|
|
||||||
items.push(isStaged
|
|
||||||
? { icon: Icon.minus({}), label: "Unstage changes", onClick: () => unstage(target.path) }
|
|
||||||
: { icon: Icon.plus({}), label: "Stage changes", onClick: () => stage(target.path) });
|
|
||||||
items.push({ icon: Icon.diff({}), label: "Open diff", onClick: () => openFile(target.path, { diff: true }) });
|
|
||||||
}
|
|
||||||
items.push({ icon: Icon.file({}), label: "Open file", onClick: () => openFile(target.path) });
|
|
||||||
items.push({ icon: Icon.reveal({}), label: "Reveal in Explorer", onClick: () => reveal(target.path) });
|
|
||||||
}
|
|
||||||
setMenu({ x: e.clientX, y: e.clientY, note: ref, items });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- shortcuts ----
|
|
||||||
useEffect(() => {
|
|
||||||
function onKey(e) {
|
|
||||||
const meta = e.metaKey || e.ctrlKey;
|
|
||||||
if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); setOverlay("search"); }
|
|
||||||
else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active) closeTab(active); }
|
|
||||||
else if (e.key === "Escape") { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null); } }
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [active, splitFor]);
|
|
||||||
|
|
||||||
const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" };
|
|
||||||
const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" };
|
|
||||||
const resolvedTabs = tabs.map((t) => {
|
|
||||||
const changed = !!PROJECT.diffs[t.path];
|
|
||||||
const m = tabMode[t.path] || (changed ? "diff" : "code");
|
|
||||||
return { ...t, changed, modeLabel: splitFor === t.path ? "split" : MODE_LABEL[m] };
|
|
||||||
});
|
|
||||||
const mode = tabMode[active] || (PROJECT.diffs[active] ? "diff" : "code");
|
|
||||||
const totals = PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
|
|
||||||
const activeLang = active ? HL.langLabel(active) : "";
|
|
||||||
|
|
||||||
const crumb = active ? active.split("/") : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
{/* title bar */}
|
|
||||||
<div className="titlebar">
|
|
||||||
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
|
|
||||||
<div className="tb-title">{Icon.spark({ style: { color: "var(--accent)" } })}<b>Helder</b><span style={{ color: "var(--fg-3)" }}>—</span><span style={{ color: "var(--fg-2)" }}>{PROJECT.name}</span></div>
|
|
||||||
{active && (
|
|
||||||
<div className="tb-crumb">
|
|
||||||
{crumb.map((s, i) => (<React.Fragment key={i}>{i > 0 && <span className="seg"> › </span>}<span style={i === crumb.length - 1 ? { color: "var(--fg-1)" } : null}>{s}</span></React.Fragment>))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="tb-spacer" />
|
|
||||||
<div className="tb-actions">
|
|
||||||
<button className="tb-btn" onClick={() => setOverlay("search")}>{Icon.search({})} Search <kbd>⌘F</kbd></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* workbench */}
|
|
||||||
<div className="workbench">
|
|
||||||
<div className="col" style={{ width: gitW, flex: "0 0 " + gitW + "px" }}>
|
|
||||||
<GitPanel changes={PROJECT.changes} staged={staged} committed={committed}
|
|
||||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
|
||||||
onStage={stage} onUnstage={unstage} onStageAll={stageAll} onUnstageAll={unstageAll} onCommit={commit}
|
|
||||||
onOpen={openFile} onContext={openMenu} activePath={active} />
|
|
||||||
</div>
|
|
||||||
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
|
|
||||||
|
|
||||||
<div className="col" style={{ width: treeW, flex: "0 0 " + treeW + "px" }}>
|
|
||||||
<FileTree tree={PROJECT.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
|
|
||||||
onContext={openMenu} activePath={active} changeMap={changeMap} committed={committed} />
|
|
||||||
</div>
|
|
||||||
<Splitter onDelta={(dx) => setTreeW((w) => clamp(w + dx, 160, 520))} />
|
|
||||||
|
|
||||||
<div className="col editor-col">
|
|
||||||
<Editor tabs={resolvedTabs} active={active} mode={mode}
|
|
||||||
setMode={(m) => { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); }}
|
|
||||||
onActivate={setActive} onClose={closeTab} onContext={openMenu}
|
|
||||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
|
||||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} />
|
|
||||||
</div>
|
|
||||||
<Splitter onDelta={(dx) => setRightW((w) => clamp(w - dx, 280, 780))} />
|
|
||||||
|
|
||||||
<RightColumn width={rightW} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* status bar */}
|
|
||||||
<div className="statusbar">
|
|
||||||
<div className="sb accent">{Icon.branch({ width: 12, height: 12 })}<span style={{ color: "#0c1320" }}>{PROJECT.branch}</span></div>
|
|
||||||
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">−{totals.del}</span></div>
|
|
||||||
<div className="sb spacer" />
|
|
||||||
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${cursor.path === active ? cursor.line : 1}, Col ${cursor.path === active ? cursor.col : 1}`}</div>}
|
|
||||||
{active && <div className="sb">Spaces: 4</div>}
|
|
||||||
{active && <div className="sb">UTF-8</div>}
|
|
||||||
{active && <div className="sb"><b>{activeLang}</b></div>}
|
|
||||||
{active && PROJECT.diffs[active] && <div className="sb">{splitFor === active ? "Split" : (MODE_WORD[mode] || "")}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* overlays */}
|
|
||||||
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
|
||||||
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref}
|
|
||||||
onConfirm={(text) => {
|
|
||||||
const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.ref;
|
|
||||||
window.dispatchEvent(new CustomEvent("agentPaste", { detail: line }));
|
|
||||||
setPassPopup(null);
|
|
||||||
toast("Passed to agent", passPopup.ref);
|
|
||||||
}}
|
|
||||||
onCancel={() => setPassPopup(null)} />}
|
|
||||||
{overlay === "search" && <SearchModal onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
|
||||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
|
||||||
<Toasts toasts={toasts} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/* Shared icons, FileIcon, GitPanel, FileTree */
|
|
||||||
const { useState, useEffect, useRef, useMemo, useCallback } = React;
|
|
||||||
|
|
||||||
/* ---- minimal geometric icons ---- */
|
|
||||||
const Icon = {
|
|
||||||
search: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.4"/><line x1="10.5" y1="10.5" x2="14" y2="14" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
|
||||||
branch: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><circle cx="4" cy="3.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="4" cy="12.5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><circle cx="12" cy="5" r="1.8" stroke="currentColor" strokeWidth="1.3"/><path d="M4 5.3v5.4M5.8 5C9 5 10 6.2 10 9v0" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
|
||||||
close: (p) => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none" {...p}><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"/></svg>),
|
|
||||||
copy: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3"/><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.3" fill="none"/></svg>),
|
|
||||||
terminal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
||||||
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round"/></svg>),
|
|
||||||
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none"/><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
|
||||||
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none"/></svg>),
|
|
||||||
diff: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/></svg>),
|
|
||||||
plus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
|
||||||
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>),
|
|
||||||
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
||||||
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round"/><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>),
|
|
||||||
};
|
|
||||||
|
|
||||||
const Chevron = ({ open }) => (
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FolderIcon = ({ open }) => (
|
|
||||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path d={open ? "M1.5 4.5h4l1.2 1.4H14V13H2V4.5z" : "M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z"}
|
|
||||||
fill={open ? "rgba(122,131,140,.18)" : "rgba(122,131,140,.12)"} stroke="currentColor" strokeWidth="1.1" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
function FileIcon({ path }) {
|
|
||||||
const ic = HL.iconFor(path);
|
|
||||||
return <span className="ficon" style={{ background: ic.c }}><span>{ic.t}</span></span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Git / Source Control panel ============ */
|
|
||||||
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }) {
|
|
||||||
const name = c.path.split("/").pop();
|
|
||||||
const dir = c.path.split("/").slice(0, -1).join("/");
|
|
||||||
return (
|
|
||||||
<div className={"git-row" + (activePath === c.path ? " active" : "")}
|
|
||||||
onClick={() => onOpen(c.path, { diff: true })}
|
|
||||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: "git", staged })}
|
|
||||||
title={c.path}>
|
|
||||||
<span className={"git-stat " + c.status}>{c.status}</span>
|
|
||||||
<FileIcon path={c.path} />
|
|
||||||
<span className={"git-name" + (c.deleted ? " del" : "")}>{name}</span>
|
|
||||||
{dir && <span className="git-dir">{dir}/</span>}
|
|
||||||
<button className="git-act" title={staged ? "Unstage changes" : "Stage changes"}
|
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path); }}>
|
|
||||||
{staged ? Icon.minus({}) : Icon.plus({})}
|
|
||||||
</button>
|
|
||||||
<span className="git-delta">
|
|
||||||
{c.add > 0 && <span className="a">+{c.add}</span>}
|
|
||||||
{c.del > 0 && <span className="d">-{c.del}</span>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GitPanel({ changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }) {
|
|
||||||
const visible = changes.filter((c) => !committed.has(c.path));
|
|
||||||
const stagedList = visible.filter((c) => staged.has(c.path));
|
|
||||||
const changesList = visible.filter((c) => !staged.has(c.path));
|
|
||||||
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 });
|
|
||||||
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="phead">
|
|
||||||
{Icon.branch({})}<span>Source Control</span>
|
|
||||||
<span className="ct">{visible.length}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="commit-box">
|
|
||||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
|
||||||
placeholder="Message (⌘↵ to commit)"
|
|
||||||
onChange={(e) => setCommitMsg(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit) { e.preventDefault(); onCommit(); } }} />
|
|
||||||
<button className="commit-btn" disabled={!canCommit} onClick={onCommit}
|
|
||||||
title={canCommit ? "Commit staged changes" : "Stage files and write a message to commit"}>
|
|
||||||
{Icon.check({})}<span>Commit{stagedList.length ? " " + stagedList.length : ""}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="git-body">
|
|
||||||
{visible.length === 0 ? (
|
|
||||||
<div className="git-empty">{Icon.check({ width: 20, height: 20 })}<span>No changes — working tree clean</span></div>
|
|
||||||
) : (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="git-group">
|
|
||||||
Staged Changes <span className="gc">{stagedList.length}</span>
|
|
||||||
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus({})}</button>}
|
|
||||||
</div>
|
|
||||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
|
||||||
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
|
|
||||||
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-divider" />
|
|
||||||
|
|
||||||
<div className="git-group">
|
|
||||||
Changes <span className="gc">{changesList.length}</span>
|
|
||||||
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus({})}</button>}
|
|
||||||
</div>
|
|
||||||
{changesList.length > 0 ? changesList.map((c) => (
|
|
||||||
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
|
|
||||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
|
||||||
)) : (
|
|
||||||
<div className="git-none">All changes staged</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="git-foot">
|
|
||||||
<span className="branch-chip">{Icon.branch({})}<b>{PROJECT.branch}</b></span>
|
|
||||||
<span style={{ marginLeft: "auto", fontFamily: "var(--mono)" }}>
|
|
||||||
<span className="a" style={{ color: "var(--add)" }}>+{totals.add}</span>{" "}
|
|
||||||
<span className="d" style={{ color: "var(--del)" }}>-{totals.del}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ File Tree ============ */
|
|
||||||
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
|
||||||
const pad = 10 + depth * 13;
|
|
||||||
if (node.type === "dir") {
|
|
||||||
const isOpen = openDirs.has(node.path) || node.path === "";
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
{node.path !== "" && (
|
|
||||||
<div className="tree-row folder" style={{ paddingLeft: pad }}
|
|
||||||
onClick={() => toggleDir(node.path)}
|
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "dir" })}>
|
|
||||||
<span className="tw"><Chevron open={isOpen} /></span>
|
|
||||||
<FolderIcon open={isOpen} />
|
|
||||||
<span className="tree-label">{node.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isOpen && node.children.map((c) => (
|
|
||||||
<TreeNode key={c.path} node={c} depth={node.path === "" ? 0 : depth + 1}
|
|
||||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
|
||||||
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
|
||||||
))}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path];
|
|
||||||
return (
|
|
||||||
<div className={"tree-row" + (activePath === node.path ? " active" : "")}
|
|
||||||
style={{ paddingLeft: pad + 2 }}
|
|
||||||
onClick={() => onOpen(node.path)}
|
|
||||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: "file" })}
|
|
||||||
title={node.path}>
|
|
||||||
<span className="tw" />
|
|
||||||
<FileIcon path={node.path} />
|
|
||||||
<span className="tree-label" style={status === "D" ? { textDecoration: "line-through", color: "var(--fg-3)" } : null}>{node.name}</span>
|
|
||||||
{status && <span className={"tree-badge " + status}>{status}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }) {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<div className="phead">
|
|
||||||
<span>Explorer</span>
|
|
||||||
<span style={{ marginLeft: "auto", color: "var(--fg-3)", textTransform: "none", letterSpacing: 0, fontFamily: "var(--mono)", fontSize: 10.5 }}>{tree.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="tree-body">
|
|
||||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
|
||||||
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, { Icon, Chevron, FolderIcon, FileIcon, GitPanel, FileTree });
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
|
|
||||||
(function () {
|
|
||||||
// ---- working-tree (current / updated) file contents ----------------
|
|
||||||
const F = {};
|
|
||||||
|
|
||||||
F["src/Http/Controller/UserController.php"] = `<?php
|
|
||||||
|
|
||||||
namespace App\\Http\\Controller;
|
|
||||||
|
|
||||||
use App\\Service\\PaymentService;
|
|
||||||
use App\\Repository\\UserRepository;
|
|
||||||
use Psr\\Http\\Message\\ResponseInterface;
|
|
||||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
|
||||||
|
|
||||||
final class UserController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly UserRepository $users,
|
|
||||||
private readonly PaymentService $payments,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
|
||||||
{
|
|
||||||
$user = $this->users->find($id);
|
|
||||||
|
|
||||||
if ($user === null) {
|
|
||||||
return $this->json(['error' => 'user_not_found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$balance = $this->payments->balanceFor($user);
|
|
||||||
|
|
||||||
return $this->json([
|
|
||||||
'id' => $user->id,
|
|
||||||
'email' => $user->email,
|
|
||||||
'plan' => $user->plan->value,
|
|
||||||
'name' => $user->name,
|
|
||||||
'currency' => $user->currency,
|
|
||||||
'balance' => $balance->toArray(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
|
||||||
{
|
|
||||||
$user = $this->users->find($id);
|
|
||||||
if ($user === null) {
|
|
||||||
return $this->json(['error' => 'user_not_found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = (array) $request->getParsedBody();
|
|
||||||
$user->fill($this->onlyFillable($data));
|
|
||||||
$this->users->save($user);
|
|
||||||
|
|
||||||
return $this->json($user->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string,mixed> */
|
|
||||||
private function onlyFillable(array $data): array
|
|
||||||
{
|
|
||||||
$allowed = ['email', 'plan', 'name'];
|
|
||||||
return array_intersect_key($data, array_flip($allowed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["src/Service/PaymentService.php"] = `<?php
|
|
||||||
|
|
||||||
namespace App\\Service;
|
|
||||||
|
|
||||||
use App\\Entity\\User;
|
|
||||||
use App\\ValueObject\\Money;
|
|
||||||
use App\\Gateway\\PaymentGateway;
|
|
||||||
use Psr\\Log\\LoggerInterface;
|
|
||||||
|
|
||||||
final class PaymentService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly PaymentGateway $gateway,
|
|
||||||
private readonly LoggerInterface $logger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function balanceFor(User $user): Money
|
|
||||||
{
|
|
||||||
$cents = $this->gateway->lookupBalance($user->id);
|
|
||||||
return Money::fromCents($cents, $user->currency ?? 'EUR');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function charge(User $user, Money $amount, string $reason): bool
|
|
||||||
{
|
|
||||||
if ($amount->isZero()) {
|
|
||||||
$this->logger->warning('Skipped zero charge', ['user' => $user->id]);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $this->gateway->charge($user->paymentToken, $amount->cents());
|
|
||||||
$this->logger->info('Charge attempt', [
|
|
||||||
'user' => $user->id,
|
|
||||||
'amount' => $amount->cents(),
|
|
||||||
'ok' => $result->success,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $result->success;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["public/assets/app.js"] = `import { createStore } from './store.js';
|
|
||||||
import { mountRouter } from './router.js';
|
|
||||||
|
|
||||||
const store = createStore({
|
|
||||||
user: null,
|
|
||||||
notifications: [],
|
|
||||||
theme: 'dark',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const res = await fetch('/api/session', { credentials: 'include' });
|
|
||||||
if (res.ok) {
|
|
||||||
const session = await res.json();
|
|
||||||
store.set('user', session.user);
|
|
||||||
store.set('theme', session.user.theme ?? 'dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
mountRouter(document.querySelector('#app'), store);
|
|
||||||
store.subscribe('notifications', renderToasts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToasts(list) {
|
|
||||||
const host = document.querySelector('#toasts');
|
|
||||||
host.replaceChildren(...list.map((n) => {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
|
||||||
el.textContent = n.message;
|
|
||||||
return el;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["public/assets/store.js"] = `export function createStore(initial = {}) {
|
|
||||||
let state = { ...initial };
|
|
||||||
const subs = new Map();
|
|
||||||
|
|
||||||
return {
|
|
||||||
get: (key) => state[key],
|
|
||||||
set(key, value) {
|
|
||||||
state = { ...state, [key]: value };
|
|
||||||
(subs.get(key) || []).forEach((fn) => fn(value, state));
|
|
||||||
},
|
|
||||||
subscribe(key, fn) {
|
|
||||||
const list = subs.get(key) || [];
|
|
||||||
list.push(fn);
|
|
||||||
subs.set(key, list);
|
|
||||||
return () => subs.set(key, list.filter((f) => f !== fn));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["public/assets/styles.css"] = `:root {
|
|
||||||
--brand: #4d8dff;
|
|
||||||
--ink: #15171a;
|
|
||||||
--paper: #ffffff;
|
|
||||||
--radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: system-ui, sans-serif;
|
|
||||||
background: var(--ink);
|
|
||||||
color: #e6e8ea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border-left: 3px solid var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast--error { border-left-color: #e0696a; }
|
|
||||||
.toast--success { border-left-color: #5cbd6b; }
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
plan: Plan;
|
|
||||||
currency: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Balance {
|
|
||||||
cents: number;
|
|
||||||
currency: string;
|
|
||||||
formatted: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ApiResult<T> =
|
|
||||||
| { ok: true; data: T }
|
|
||||||
| { ok: false; error: string; status: number };
|
|
||||||
|
|
||||||
export async function getUser(id: string): Promise<ApiResult<User>> {
|
|
||||||
const res = await fetch(\\\`/api/users/\\\${id}\\\`);
|
|
||||||
if (!res.ok) {
|
|
||||||
return { ok: false, error: 'request_failed', status: res.status };
|
|
||||||
}
|
|
||||||
return { ok: true, data: (await res.json()) as User };
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["scripts/migrate.py"] = `#!/usr/bin/env python3
|
|
||||||
"""Run pending database migrations in order."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from db import connect, applied_migrations
|
|
||||||
|
|
||||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
|
||||||
|
|
||||||
|
|
||||||
def pending(conn):
|
|
||||||
done = applied_migrations(conn)
|
|
||||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
|
||||||
return [f for f in files if f.stem not in done]
|
|
||||||
|
|
||||||
|
|
||||||
def run(conn, migration: Path) -> None:
|
|
||||||
sql = migration.read_text()
|
|
||||||
print(f" -> applying {migration.stem}")
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(sql)
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
|
||||||
(migration.stem,),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
conn = connect()
|
|
||||||
todo = pending(conn)
|
|
||||||
if not todo:
|
|
||||||
print("Database is up to date.")
|
|
||||||
return 0
|
|
||||||
print(f"Applying {len(todo)} migration(s)...")
|
|
||||||
for migration in todo:
|
|
||||||
run(conn, migration)
|
|
||||||
print("Done.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["scripts/seed.py"] = `#!/usr/bin/env python3
|
|
||||||
"""Seed the database with demo data for local development."""
|
|
||||||
import random
|
|
||||||
from db import connect
|
|
||||||
|
|
||||||
PLANS = ["free", "pro", "enterprise"]
|
|
||||||
|
|
||||||
|
|
||||||
def seed_users(conn, count: int = 25) -> None:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
for i in range(count):
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO users (email, plan) VALUES (%s, %s)",
|
|
||||||
(f"user{i}@example.com", random.choice(PLANS)),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
print(f"Seeded {count} users.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
seed_users(connect())
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["templates/dashboard.html"] = `<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Dashboard</title>
|
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main id="app" class="layout">
|
|
||||||
<header class="topbar">
|
|
||||||
<h1 class="logo">Console</h1>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/users" class="nav__link">Users</a>
|
|
||||||
<a href="/billing" class="nav__link">Billing</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<section id="content" class="content"></section>
|
|
||||||
</main>
|
|
||||||
<div id="toasts" class="toast-host"></div>
|
|
||||||
<script type="module" src="/assets/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["config/app.json"] = `{
|
|
||||||
"name": "console",
|
|
||||||
"env": "production",
|
|
||||||
"features": {
|
|
||||||
"billing": true,
|
|
||||||
"newDashboard": true,
|
|
||||||
"exportCsv": false
|
|
||||||
},
|
|
||||||
"payment": {
|
|
||||||
"gateway": "stripe",
|
|
||||||
"currency": "EUR",
|
|
||||||
"retryLimit": 3
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "info",
|
|
||||||
"channel": "stdout"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["composer.json"] = `{
|
|
||||||
"name": "blijnder/console",
|
|
||||||
"type": "project",
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.2",
|
|
||||||
"psr/log": "^3.0",
|
|
||||||
"psr/http-message": "^2.0",
|
|
||||||
"nyholm/psr7": "^1.8"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^11.0",
|
|
||||||
"phpstan/phpstan": "^1.11"
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": { "App\\\\": "src/" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["package.json"] = `{
|
|
||||||
"name": "console-frontend",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"test": "vitest run",
|
|
||||||
"lint": "eslint ."
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"vite": "^5.3.0",
|
|
||||||
"vitest": "^2.0.0",
|
|
||||||
"typescript": "^5.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
F["README.md"] = `# Console
|
|
||||||
|
|
||||||
Internal admin console. PHP API + small vanilla JS frontend.
|
|
||||||
|
|
||||||
## Getting started
|
|
||||||
|
|
||||||
composer install
|
|
||||||
npm install
|
|
||||||
python scripts/migrate.py
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
|
|
||||||
- \`public/\` Document root and frontend assets
|
|
||||||
- \`scripts/\` Python maintenance + migration scripts
|
|
||||||
- \`templates/\` Server-rendered HTML
|
|
||||||
`;
|
|
||||||
|
|
||||||
F[".env"] = `APP_ENV=production
|
|
||||||
APP_DEBUG=false
|
|
||||||
DATABASE_URL=postgres://localhost:5432/console
|
|
||||||
PAYMENT_GATEWAY=stripe
|
|
||||||
PAYMENT_CURRENCY=EUR
|
|
||||||
LOG_LEVEL=info
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ---- ORIGINAL (pre-edit) versions of changed files ----------------
|
|
||||||
const O = {};
|
|
||||||
|
|
||||||
O["src/Http/Controller/UserController.php"] = `<?php
|
|
||||||
|
|
||||||
namespace App\\Http\\Controller;
|
|
||||||
|
|
||||||
use App\\Service\\PaymentService;
|
|
||||||
use App\\Repository\\UserRepository;
|
|
||||||
use Psr\\Http\\Message\\ResponseInterface;
|
|
||||||
use Psr\\Http\\Message\\ServerRequestInterface;
|
|
||||||
|
|
||||||
final class UserController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly UserRepository $users,
|
|
||||||
private readonly PaymentService $payments,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function show(ServerRequestInterface $request, string $id): ResponseInterface
|
|
||||||
{
|
|
||||||
$user = $this->users->find($id);
|
|
||||||
|
|
||||||
if ($user === null) {
|
|
||||||
return $this->json(['error' => 'user_not_found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$balance = $this->payments->balanceFor($user);
|
|
||||||
|
|
||||||
return $this->json([
|
|
||||||
'id' => $user->id,
|
|
||||||
'email' => $user->email,
|
|
||||||
'plan' => $user->plan,
|
|
||||||
'balance' => $balance->toArray(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(ServerRequestInterface $request, string $id): ResponseInterface
|
|
||||||
{
|
|
||||||
$user = $this->users->find($id);
|
|
||||||
if ($user === null) {
|
|
||||||
return $this->json(['error' => 'user_not_found'], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = (array) $request->getParsedBody();
|
|
||||||
$user->fill($this->onlyFillable($data));
|
|
||||||
$this->users->save($user);
|
|
||||||
|
|
||||||
return $this->json($user->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onlyFillable(array $data): array
|
|
||||||
{
|
|
||||||
return array_intersect_key($data, array_flip(['email', 'plan']));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
O["public/assets/app.js"] = `import { createStore } from './store.js';
|
|
||||||
import { mountRouter } from './router.js';
|
|
||||||
|
|
||||||
const store = createStore({
|
|
||||||
user: null,
|
|
||||||
notifications: [],
|
|
||||||
theme: 'dark',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
const res = await fetch('/api/session');
|
|
||||||
if (res.ok) {
|
|
||||||
const session = await res.json();
|
|
||||||
store.set('user', session.user);
|
|
||||||
}
|
|
||||||
|
|
||||||
mountRouter(document.querySelector('#app'), store);
|
|
||||||
store.subscribe('notifications', renderToasts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToasts(list) {
|
|
||||||
const host = document.querySelector('#toasts');
|
|
||||||
host.replaceChildren(...list.map((n) => {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = \\\`toast toast--\\\${n.level}\\\`;
|
|
||||||
el.textContent = n.message;
|
|
||||||
return el;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
|
||||||
`;
|
|
||||||
|
|
||||||
O["config/app.json"] = `{
|
|
||||||
"name": "console",
|
|
||||||
"env": "production",
|
|
||||||
"features": {
|
|
||||||
"billing": true,
|
|
||||||
"newDashboard": false
|
|
||||||
},
|
|
||||||
"payment": {
|
|
||||||
"gateway": "stripe",
|
|
||||||
"currency": "EUR",
|
|
||||||
"retryLimit": 3
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"level": "info",
|
|
||||||
"channel": "stdout"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
O["scripts/migrate.py"] = `#!/usr/bin/env python3
|
|
||||||
"""Run pending database migrations in order."""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from db import connect, applied_migrations
|
|
||||||
|
|
||||||
MIGRATIONS = Path(__file__).parent / "migrations"
|
|
||||||
|
|
||||||
|
|
||||||
def pending(conn):
|
|
||||||
done = applied_migrations(conn)
|
|
||||||
files = sorted(MIGRATIONS.glob("*.sql"))
|
|
||||||
return [f for f in files if f.stem not in done]
|
|
||||||
|
|
||||||
|
|
||||||
def run(conn, migration: Path) -> None:
|
|
||||||
sql = migration.read_text()
|
|
||||||
print(f" -> applying {migration.stem}")
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(sql)
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO schema_migrations (version) VALUES (%s)",
|
|
||||||
(migration.stem,),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
conn = connect()
|
|
||||||
todo = pending(conn)
|
|
||||||
if not todo:
|
|
||||||
print("Database is up to date.")
|
|
||||||
return 0
|
|
||||||
for migration in todo:
|
|
||||||
run(conn, migration)
|
|
||||||
print("Done.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
`;
|
|
||||||
|
|
||||||
// PaymentService is a brand-new file (added) -> original is empty
|
|
||||||
O["src/Service/PaymentService.php"] = "";
|
|
||||||
|
|
||||||
// LegacyUser was deleted -> original content, no working-tree version
|
|
||||||
O["src/Model/LegacyUser.php"] = `<?php
|
|
||||||
|
|
||||||
namespace App\\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Superseded by App\\Entity\\User. Kept only for the
|
|
||||||
* legacy billing import; safe to remove once the importer is gone.
|
|
||||||
*/
|
|
||||||
final class LegacyUser
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly int $id,
|
|
||||||
public readonly string $email,
|
|
||||||
public readonly ?string $plan = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public static function fromRow(array $row): self
|
|
||||||
{
|
|
||||||
return new self(
|
|
||||||
(int) $row['id'],
|
|
||||||
(string) $row['email'],
|
|
||||||
$row['plan'] ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'email' => $this->email,
|
|
||||||
'plan' => $this->plan,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// ---- file tree (nested) -------------------------------------------
|
|
||||||
const tree = {
|
|
||||||
name: "console", type: "dir", path: "", open: true, children: [
|
|
||||||
{ name: "config", type: "dir", path: "config", open: false, children: [
|
|
||||||
{ name: "app.json", type: "file", path: "config/app.json" },
|
|
||||||
]},
|
|
||||||
{ name: "public", type: "dir", path: "public", open: true, children: [
|
|
||||||
{ name: "assets", type: "dir", path: "public/assets", open: true, children: [
|
|
||||||
{ name: "app.js", type: "file", path: "public/assets/app.js" },
|
|
||||||
{ name: "store.js", type: "file", path: "public/assets/store.js" },
|
|
||||||
{ name: "styles.css", type: "file", path: "public/assets/styles.css" },
|
|
||||||
]},
|
|
||||||
]},
|
|
||||||
{ name: "scripts", type: "dir", path: "scripts", open: false, children: [
|
|
||||||
{ name: "migrate.py", type: "file", path: "scripts/migrate.py" },
|
|
||||||
{ name: "seed.py", type: "file", path: "scripts/seed.py" },
|
|
||||||
]},
|
|
||||||
{ name: "src", type: "dir", path: "src", open: true, children: [
|
|
||||||
{ name: "Http", type: "dir", path: "src/Http", open: true, children: [
|
|
||||||
{ name: "Controller", type: "dir", path: "src/Http/Controller", open: true, children: [
|
|
||||||
{ name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" },
|
|
||||||
]},
|
|
||||||
]},
|
|
||||||
{ name: "Service", type: "dir", path: "src/Service", open: true, children: [
|
|
||||||
{ name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" },
|
|
||||||
]},
|
|
||||||
{ name: "types", type: "dir", path: "src/types", open: false, children: [
|
|
||||||
{ name: "api.ts", type: "file", path: "src/types/api.ts" },
|
|
||||||
]},
|
|
||||||
]},
|
|
||||||
{ name: "templates", type: "dir", path: "templates", open: false, children: [
|
|
||||||
{ name: "dashboard.html", type: "file", path: "templates/dashboard.html" },
|
|
||||||
]},
|
|
||||||
{ name: ".env", type: "file", path: ".env" },
|
|
||||||
{ name: "composer.json", type: "file", path: "composer.json" },
|
|
||||||
{ name: "package.json", type: "file", path: "package.json" },
|
|
||||||
{ name: "README.md", type: "file", path: "README.md" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- line-based LCS diff ------------------------------------------
|
|
||||||
function buildDiff(origText, updText) {
|
|
||||||
const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n");
|
|
||||||
const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n");
|
|
||||||
const n = a.length, m = b.length;
|
|
||||||
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
|
|
||||||
for (let i = n - 1; i >= 0; i--)
|
|
||||||
for (let j = m - 1; j >= 0; j--)
|
|
||||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
||||||
|
|
||||||
const ops = [];
|
|
||||||
let i = 0, j = 0;
|
|
||||||
while (i < n && j < m) {
|
|
||||||
if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; }
|
|
||||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; }
|
|
||||||
else { ops.push({ t: "add", b: j }); j++; }
|
|
||||||
}
|
|
||||||
while (i < n) { ops.push({ t: "del", a: i++ }); }
|
|
||||||
while (j < m) { ops.push({ t: "add", b: j++ }); }
|
|
||||||
|
|
||||||
const rows = [], left = [], right = [], split = [];
|
|
||||||
const delSet = new Set(), addSet = new Set();
|
|
||||||
let add = 0, del = 0;
|
|
||||||
for (const op of ops) {
|
|
||||||
if (op.t === "same") {
|
|
||||||
rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
|
|
||||||
} else if (op.t === "del") {
|
|
||||||
rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
|
|
||||||
delSet.add(op.a); del++;
|
|
||||||
} else {
|
|
||||||
rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
|
|
||||||
addSet.add(op.b); add++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
|
|
||||||
b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
|
|
||||||
|
|
||||||
// aligned split rows (pair del/add blocks)
|
|
||||||
let dbuf = [], abuf = [];
|
|
||||||
const flush = () => {
|
|
||||||
const k = Math.max(dbuf.length, abuf.length);
|
|
||||||
for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
|
|
||||||
dbuf = []; abuf = [];
|
|
||||||
};
|
|
||||||
for (const op of ops) {
|
|
||||||
if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
|
|
||||||
else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
|
|
||||||
else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
|
|
||||||
}
|
|
||||||
flush();
|
|
||||||
|
|
||||||
return { rows, left, right, split, add, del };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- changed files -------------------------------------------------
|
|
||||||
const changeDefs = [
|
|
||||||
{ path: "src/Service/PaymentService.php", status: "A" },
|
|
||||||
{ path: "src/Http/Controller/UserController.php", status: "M" },
|
|
||||||
{ path: "public/assets/app.js", status: "M" },
|
|
||||||
{ path: "config/app.json", status: "M" },
|
|
||||||
{ path: "scripts/migrate.py", status: "M" },
|
|
||||||
{ path: "src/Model/LegacyUser.php", status: "D" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const diffs = {};
|
|
||||||
const changes = changeDefs.map((c) => {
|
|
||||||
const orig = O[c.path] != null ? O[c.path] : "";
|
|
||||||
const upd = F[c.path] != null ? F[c.path] : "";
|
|
||||||
const d = buildDiff(orig, upd);
|
|
||||||
diffs[c.path] = Object.assign(d, {
|
|
||||||
deleted: c.status === "D",
|
|
||||||
added: c.status === "A",
|
|
||||||
original: orig,
|
|
||||||
updated: upd,
|
|
||||||
});
|
|
||||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
|
|
||||||
});
|
|
||||||
|
|
||||||
window.PROJECT = {
|
|
||||||
name: "console",
|
|
||||||
branch: "feat/payments-balance",
|
|
||||||
files: F,
|
|
||||||
originals: O,
|
|
||||||
tree,
|
|
||||||
diffs,
|
|
||||||
changes,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
|
|
||||||
|
|
||||||
function climbToLine(node) {
|
|
||||||
let el = node && node.nodeType === 3 ? node.parentElement : node;
|
|
||||||
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
|
|
||||||
return el || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditorTabs({ tabs, active, onActivate, onClose }) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current && ref.current.querySelector(".tab.active");
|
|
||||||
if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
||||||
}, [active]);
|
|
||||||
return (
|
|
||||||
<div className="tabs" ref={ref}>
|
|
||||||
{tabs.map((t) => {
|
|
||||||
const name = t.path.split("/").pop();
|
|
||||||
return (
|
|
||||||
<div key={t.path}
|
|
||||||
className={"tab" + (active === t.path ? " active" : "")}
|
|
||||||
onClick={() => onActivate(t.path)}
|
|
||||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path); } }}
|
|
||||||
title={t.path}>
|
|
||||||
<FileIcon path={t.path} />
|
|
||||||
<span className="tname">{name}</span>
|
|
||||||
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
|
|
||||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path); }}>
|
|
||||||
{Icon.close({})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
|
||||||
function PaneView({ cacheKey, path, lines, lang, showSign, refLine, cursor, selection, setCursor, setSelection, onContext }) {
|
|
||||||
const anchorRef = useRef(null);
|
|
||||||
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey]);
|
|
||||||
|
|
||||||
function gutterClick(e, no) {
|
|
||||||
if (no == null) return;
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.shiftKey && anchorRef.current != null) {
|
|
||||||
const a = anchorRef.current;
|
|
||||||
setSelection({ path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
|
|
||||||
} else {
|
|
||||||
anchorRef.current = no;
|
|
||||||
setSelection({ path, start: no, end: no, anchor: no });
|
|
||||||
}
|
|
||||||
setCursor({ path, line: no, col: 1 });
|
|
||||||
}
|
|
||||||
function caretCol(sel) {
|
|
||||||
try {
|
|
||||||
const el = climbToLine(sel.focusNode);
|
|
||||||
const code = el.querySelector(".ln-code");
|
|
||||||
const r = document.createRange();
|
|
||||||
r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
|
|
||||||
return r.toString().length + 1;
|
|
||||||
} catch (e) { return 1; }
|
|
||||||
}
|
|
||||||
function onMouseUp() {
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (sel && !sel.isCollapsed) {
|
|
||||||
const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
|
|
||||||
if (a && f) {
|
|
||||||
const an = +a.dataset.line, fn = +f.dataset.line;
|
|
||||||
const s = Math.min(an, fn), e = Math.max(an, fn);
|
|
||||||
if (s !== e) { setSelection({ path, start: s, end: e, anchor: an }); setCursor({ path, line: fn, col: caretCol(sel) }); return; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (sel && sel.focusNode) {
|
|
||||||
const el = climbToLine(sel.focusNode);
|
|
||||||
if (el) { setCursor({ path, line: +el.dataset.line, col: caretCol(sel) }); setSelection(null); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleContext(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const sel = window.getSelection();
|
|
||||||
let info = { path, kind: "editor" };
|
|
||||||
const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
|
|
||||||
const f = sel && sel.focusNode && climbToLine(sel.focusNode);
|
|
||||||
if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
|
|
||||||
const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
|
|
||||||
info.sel = { start: s, end: en }; info.line = s;
|
|
||||||
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
|
||||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start;
|
|
||||||
} else {
|
|
||||||
let no = null;
|
|
||||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
|
||||||
const el = r && climbToLine(r.startContainer);
|
|
||||||
if (el) no = +el.dataset.line;
|
|
||||||
info.line = no || (cursor && cursor.path === path ? cursor.line : 1);
|
|
||||||
}
|
|
||||||
setCursor({ path, line: info.line, col: 1 });
|
|
||||||
onContext(e, info);
|
|
||||||
}
|
|
||||||
|
|
||||||
const curLine = cursor && cursor.path === path ? cursor.line : -1;
|
|
||||||
const sel = selection && selection.path === path ? selection : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={"editor" + (showSign ? " diff" : "")} onMouseUp={onMouseUp} onContextMenu={handleContext}>
|
|
||||||
{lines.map((l, i) => {
|
|
||||||
const no = l.no;
|
|
||||||
const inSel = sel && no != null && no >= sel.start && no <= sel.end;
|
|
||||||
const cls = "ln-row"
|
|
||||||
+ (l.row === "add" ? " add" : l.row === "del" ? " del" : "")
|
|
||||||
+ (l.row === "bar-add" ? " bar-add" : l.row === "bar-del" ? " bar-del" : "")
|
|
||||||
+ (no === curLine && !inSel && !l.row ? " cursor" : "")
|
|
||||||
+ (inSel ? " selrange" : "");
|
|
||||||
return (
|
|
||||||
<div key={i} data-line={no == null ? undefined : no} className={cls}>
|
|
||||||
<span className="ln-gutter" onClick={(e) => gutterClick(e, no)}>{no == null ? "" : no}</span>
|
|
||||||
{showSign && <span className="ln-sign">{l.sign === " " || !l.sign ? "" : l.sign}</span>}
|
|
||||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: html[i] }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Build the line descriptors for a given mode. */
|
|
||||||
function buildLines(mode, diff, fileText) {
|
|
||||||
if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
|
|
||||||
if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
|
|
||||||
if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
|
|
||||||
// plain file
|
|
||||||
const arr = (fileText || "").replace(/\n$/, "").split("\n");
|
|
||||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEGMENTS = [
|
|
||||||
{ id: "original", label: "Original" },
|
|
||||||
{ id: "updated", label: "Updated" },
|
|
||||||
{ id: "diff", label: "Diff" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection }) {
|
|
||||||
const tab = tabs.find((t) => t.path === active);
|
|
||||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null;
|
|
||||||
const diff = tab ? PROJECT.diffs[tab.path] : null;
|
|
||||||
const lang = tab ? HL.langFor(tab.path) : null;
|
|
||||||
const effMode = change ? mode : "code";
|
|
||||||
|
|
||||||
let built = null;
|
|
||||||
if (tab) {
|
|
||||||
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]);
|
|
||||||
else built = buildLines("code", null, PROJECT.files[tab.path]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusWord = change ? (change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified") : "";
|
|
||||||
const activeSeg = splitOpen ? "split" : effMode;
|
|
||||||
const emptyUpdated = effMode === "updated" && built && built.lines.length === 0;
|
|
||||||
const emptyOriginal = effMode === "original" && built && built.lines.length === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
|
|
||||||
{!tab ? (
|
|
||||||
<div className="empty-ed">
|
|
||||||
<div style={{ opacity: .5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
|
||||||
<div className="big">No file open</div>
|
|
||||||
<div className="klist">
|
|
||||||
<div><span>Search files & content</span><kbd>⌘ F</kbd></div>
|
|
||||||
<div><span>Copy reference</span><kbd>right-click</kbd></div>
|
|
||||||
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="editor-wrap">
|
|
||||||
{change && (
|
|
||||||
<div className="diff-bar">
|
|
||||||
<span className={"git-stat " + change.status} style={{ width: "auto" }}>{statusWord}</span>
|
|
||||||
{change.add > 0 && <span className="a">+{change.add}</span>}
|
|
||||||
{change.del > 0 && <span className="d">−{change.del}</span>}
|
|
||||||
<div className="seg">
|
|
||||||
{SEGMENTS.map((s) => (
|
|
||||||
<button key={s.id} className={activeSeg === s.id ? "on" : ""} onClick={() => setMode(s.id)}>{s.label}</button>
|
|
||||||
))}
|
|
||||||
<button className={"split-btn" + (activeSeg === "split" ? " on" : "")} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
|
||||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2"/><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2"/></svg>
|
|
||||||
Split
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{emptyUpdated ? (
|
|
||||||
<div className="empty-ed"><div className="big" style={{ color: "var(--del)" }}>No updated version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file was deleted in the change.</div></div>
|
|
||||||
) : emptyOriginal ? (
|
|
||||||
<div className="empty-ed"><div className="big" style={{ color: "var(--add)" }}>No original version</div><div style={{ fontFamily: "var(--mono)", fontSize: 12, color: "var(--fg-3)" }}>This file is new in the change.</div></div>
|
|
||||||
) : (
|
|
||||||
<PaneView cacheKey={tab.path + ":" + effMode} path={tab.path} lines={built.lines}
|
|
||||||
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
|
|
||||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Full-screen side-by-side split view */
|
|
||||||
function SplitView({ path, onClose, onContext }) {
|
|
||||||
const diff = PROJECT.diffs[path];
|
|
||||||
const lang = HL.langFor(path);
|
|
||||||
const leftRef = useRef(null), rightRef = useRef(null);
|
|
||||||
const lock = useRef(false);
|
|
||||||
const change = PROJECT.changes.find((c) => c.path === path);
|
|
||||||
|
|
||||||
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ""), [path]);
|
|
||||||
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ""), [path]);
|
|
||||||
|
|
||||||
function sync(from, to) {
|
|
||||||
if (lock.current) return; lock.current = true;
|
|
||||||
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
|
|
||||||
requestAnimationFrame(() => { lock.current = false; });
|
|
||||||
}
|
|
||||||
function ctx(e, side) {
|
|
||||||
e.preventDefault();
|
|
||||||
let no = null;
|
|
||||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
|
|
||||||
const el = r && climbToLine(r.startContainer);
|
|
||||||
if (el) no = +el.dataset.line;
|
|
||||||
onContext(e, { path, kind: "editor", line: no || 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="split-overlay">
|
|
||||||
<div className="split-head">
|
|
||||||
<FileIcon path={path} />
|
|
||||||
<span className="sh-name">{path}</span>
|
|
||||||
{change && <span className={"git-stat " + change.status} style={{ width: "auto" }}>{change.status === "A" ? "Added" : change.status === "D" ? "Deleted" : "Modified"}</span>}
|
|
||||||
{change && change.add > 0 && <span className="a" style={{ fontFamily: "var(--mono)", color: "var(--add)" }}>+{change.add}</span>}
|
|
||||||
{change && change.del > 0 && <span className="d" style={{ fontFamily: "var(--mono)", color: "var(--del)" }}>−{change.del}</span>}
|
|
||||||
<button className="split-exit" onClick={onClose}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
||||||
Collapse <kbd>Esc</kbd>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="split-body">
|
|
||||||
<div className="split-pane left">
|
|
||||||
<div className="split-label">Original <span>before</span></div>
|
|
||||||
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={(e) => ctx(e, "l")}>
|
|
||||||
{diff.split.map((row, i) => (
|
|
||||||
<div key={i} data-line={row.l ? row.l.no : undefined} className={"ln-row" + (row.l && row.l.mark === "del" ? " bar-del" : "") + (!row.l ? " empty" : "")}>
|
|
||||||
<span className="ln-gutter">{row.l ? row.l.no : ""}</span>
|
|
||||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : "" }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="split-pane right">
|
|
||||||
<div className="split-label">Updated <span>after</span></div>
|
|
||||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={(e) => ctx(e, "r")}>
|
|
||||||
{diff.split.map((row, i) => (
|
|
||||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={"ln-row" + (row.r && row.r.mark === "add" ? " bar-add" : "") + (!row.r ? " empty" : "")}>
|
|
||||||
<span className="ln-gutter">{row.r ? row.r.no : ""}</span>
|
|
||||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : "" }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, { Editor, EditorTabs, PaneView, SplitView, buildLines, climbToLine });
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/* Syntax highlighting (Prism) + file-type icon metadata. */
|
|
||||||
(function () {
|
|
||||||
const EXT_LANG = {
|
|
||||||
php: "php", js: "javascript", mjs: "javascript", cjs: "javascript",
|
|
||||||
jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
|
|
||||||
html: "markup", xml: "markup", svg: "markup", vue: "markup",
|
|
||||||
css: "css", scss: "css", json: "json", md: "markdown",
|
|
||||||
sh: "bash", bash: "bash", yml: "yaml", yaml: "yaml", env: "bash",
|
|
||||||
};
|
|
||||||
|
|
||||||
function ext(path) {
|
|
||||||
const base = path.split("/").pop() || "";
|
|
||||||
if (base === ".env" || base.startsWith(".env")) return "env";
|
|
||||||
const i = base.lastIndexOf(".");
|
|
||||||
return i >= 0 ? base.slice(i + 1).toLowerCase() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function langFor(path) { return EXT_LANG[ext(path)] || null; }
|
|
||||||
|
|
||||||
function langLabel(path) {
|
|
||||||
const e = ext(path);
|
|
||||||
const map = {
|
|
||||||
php: "PHP", js: "JavaScript", mjs: "JavaScript", ts: "TypeScript",
|
|
||||||
tsx: "TypeScript", jsx: "JavaScript", py: "Python", html: "HTML",
|
|
||||||
css: "CSS", json: "JSON", md: "Markdown", sh: "Shell", env: "Dotenv",
|
|
||||||
yml: "YAML", yaml: "YAML",
|
|
||||||
};
|
|
||||||
return map[e] || (e ? e.toUpperCase() : "Plain Text");
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(s) {
|
|
||||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
|
||||||
}
|
|
||||||
|
|
||||||
// highlight a single line independently (keeps line numbering robust)
|
|
||||||
function hlLine(line, lang) {
|
|
||||||
if (line === "") return " ";
|
|
||||||
try {
|
|
||||||
const grammar = lang && window.Prism && Prism.languages[lang];
|
|
||||||
if (grammar) return Prism.highlight(line, grammar, lang);
|
|
||||||
} catch (e) { /* fall through */ }
|
|
||||||
return escapeHtml(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- file-type icon: colored monogram chip --------------------------
|
|
||||||
const ICONS = {
|
|
||||||
php: { c: "#a78bdb", t: "php" },
|
|
||||||
js: { c: "#e6c860", t: "js" },
|
|
||||||
mjs: { c: "#e6c860", t: "js" },
|
|
||||||
ts: { c: "#5a9bd6", t: "ts" },
|
|
||||||
tsx: { c: "#5a9bd6", t: "ts" },
|
|
||||||
jsx: { c: "#5a9bd6", t: "jsx" },
|
|
||||||
py: { c: "#5fa8d6", t: "py" },
|
|
||||||
html: { c: "#e08b6a", t: "<>" },
|
|
||||||
css: { c: "#5a9bd6", t: "{}" },
|
|
||||||
scss: { c: "#d6699e", t: "{}" },
|
|
||||||
json: { c: "#d8a85c", t: "{}" },
|
|
||||||
md: { c: "#9aa0a8", t: "md" },
|
|
||||||
env: { c: "#7fc6a0", t: "$" },
|
|
||||||
sh: { c: "#7fc6a0", t: "$" },
|
|
||||||
yml: { c: "#cf7a6a", t: "yml" },
|
|
||||||
yaml: { c: "#cf7a6a", t: "yml" },
|
|
||||||
lock: { c: "#8a8f98", t: "lk" },
|
|
||||||
};
|
|
||||||
const NAME_ICONS = {
|
|
||||||
"composer.json": { c: "#a78bdb", t: "co" },
|
|
||||||
"package.json": { c: "#cf7a6a", t: "pk" },
|
|
||||||
"README.md": { c: "#5a9bd6", t: "md" },
|
|
||||||
".env": { c: "#7fc6a0", t: "$" },
|
|
||||||
};
|
|
||||||
|
|
||||||
function iconFor(path) {
|
|
||||||
const base = path.split("/").pop() || "";
|
|
||||||
if (NAME_ICONS[base]) return NAME_ICONS[base];
|
|
||||||
return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
|
|
||||||
}
|
|
||||||
|
|
||||||
window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
|
|
||||||
})();
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
|
|
||||||
|
|
||||||
function fuzzy(q, str) {
|
|
||||||
q = q.toLowerCase(); const s = str.toLowerCase();
|
|
||||||
let i = 0; const idx = [];
|
|
||||||
for (let j = 0; j < s.length && i < q.length; j++) {
|
|
||||||
if (s[j] === q[i]) { idx.push(j); i++; }
|
|
||||||
}
|
|
||||||
return i === q.length ? idx : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Highlight({ text, idx }) {
|
|
||||||
if (!idx || !idx.length) return <span>{text}</span>;
|
|
||||||
const set = new Set(idx);
|
|
||||||
return <span>{text.split("").map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <React.Fragment key={i}>{ch}</React.Fragment>)}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchModal({ onOpen, onOpenAt, onClose, changeSet }) {
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
const [sel, setSel] = useState(0);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
const leftRef = useRef(null);
|
|
||||||
const allPaths = useMemo(() => Object.keys(PROJECT.files), []);
|
|
||||||
|
|
||||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
|
||||||
|
|
||||||
// content hits (left)
|
|
||||||
const content = useMemo(() => {
|
|
||||||
const term = q.trim();
|
|
||||||
if (term.length < 2) return [];
|
|
||||||
const low = term.toLowerCase();
|
|
||||||
const groups = [];
|
|
||||||
for (const [path, src] of Object.entries(PROJECT.files)) {
|
|
||||||
const lines = src.split("\n");
|
|
||||||
const hits = [];
|
|
||||||
lines.forEach((ln, i) => {
|
|
||||||
const ix = ln.toLowerCase().indexOf(low);
|
|
||||||
if (ix >= 0) hits.push({ no: i + 1, ln, ix });
|
|
||||||
});
|
|
||||||
if (hits.length) groups.push({ path, hits });
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, [q]);
|
|
||||||
|
|
||||||
// file-name matches (right)
|
|
||||||
const files = useMemo(() => {
|
|
||||||
const term = q.trim();
|
|
||||||
if (!term) return [];
|
|
||||||
const out = [];
|
|
||||||
for (const p of allPaths) {
|
|
||||||
const name = p.split("/").pop();
|
|
||||||
const ni = fuzzy(term, name);
|
|
||||||
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
|
|
||||||
const pi = fuzzy(term, p);
|
|
||||||
if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
|
|
||||||
}
|
|
||||||
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
|
|
||||||
return out;
|
|
||||||
}, [q]);
|
|
||||||
|
|
||||||
// flat list of content hits for keyboard nav
|
|
||||||
const flat = useMemo(() => {
|
|
||||||
const arr = [];
|
|
||||||
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
|
|
||||||
return arr;
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
const totalHits = flat.length;
|
|
||||||
useEffect(() => { setSel(0); }, [q]);
|
|
||||||
useEffect(() => {
|
|
||||||
const el = leftRef.current && leftRef.current.querySelector(".sr-line.sel");
|
|
||||||
if (el) el.scrollIntoView({ block: "nearest" });
|
|
||||||
}, [sel]);
|
|
||||||
|
|
||||||
function onKey(e) {
|
|
||||||
if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)); }
|
|
||||||
else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)); }
|
|
||||||
else if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose(); }
|
|
||||||
else if (files[0]) { onOpen(files[0].path); onClose(); }
|
|
||||||
} else if (e.key === "Escape") { e.preventDefault(); onClose(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderLine(ln, ix, len) {
|
|
||||||
const pre = ln.slice(0, ix), mid = ln.slice(ix, ix + len), post = ln.slice(ix + len);
|
|
||||||
return <span className="tx">{pre}<mark>{mid}</mark>{post}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const term = q.trim();
|
|
||||||
let flatIx = -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="scrim" onMouseDown={onClose}>
|
|
||||||
<div className="search-modal" onMouseDown={(e) => e.stopPropagation()}>
|
|
||||||
<div className="pi">
|
|
||||||
{Icon.search({ style: { color: "var(--fg-3)" } })}
|
|
||||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
|
||||||
placeholder="Search content and file names…" spellCheck={false} />
|
|
||||||
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? "" : "s"} · {files.length} file{files.length === 1 ? "" : "s"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="search-cols">
|
|
||||||
<div className="sc-left" ref={leftRef}>
|
|
||||||
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
|
||||||
{term.length < 2 && <div className="pempty">Type at least 2 characters</div>}
|
|
||||||
{term.length >= 2 && content.length === 0 && <div className="pempty">No content matches</div>}
|
|
||||||
{content.map((g) => (
|
|
||||||
<React.Fragment key={g.path}>
|
|
||||||
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
|
||||||
<FileIcon path={g.path} />
|
|
||||||
<span className="srf-name">{g.path}</span>
|
|
||||||
<span className="cnt">{g.hits.length}</span>
|
|
||||||
</div>
|
|
||||||
{g.hits.slice(0, 12).map((h) => {
|
|
||||||
flatIx++;
|
|
||||||
const me = flatIx;
|
|
||||||
return (
|
|
||||||
<div key={h.no} className={"sr-line" + (me === sel ? " sel" : "")}
|
|
||||||
onMouseEnter={() => setSel(me)}
|
|
||||||
onClick={() => { onOpenAt(g.path, h.no); onClose(); }}>
|
|
||||||
<span className="no">{h.no}</span>
|
|
||||||
{renderLine(h.ln, h.ix, term.length)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="sc-right">
|
|
||||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
|
||||||
{!term && <div className="pempty sm">Start typing…</div>}
|
|
||||||
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
|
||||||
{files.slice(0, 40).map((r) => {
|
|
||||||
const name = r.path.split("/").pop();
|
|
||||||
const dir = r.path.split("/").slice(0, -1).join("/");
|
|
||||||
return (
|
|
||||||
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose(); }} title={r.path}>
|
|
||||||
<FileIcon path={r.path} />
|
|
||||||
<div className="fres-txt">
|
|
||||||
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
|
||||||
{dir && <span className="fd">{dir}/</span>}
|
|
||||||
</div>
|
|
||||||
{changeSet.has(r.path) && <span className="tree-badge M" style={{ fontFamily: "var(--mono)", fontSize: 10 }}>•</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenu({ menu, onClose }) {
|
|
||||||
const ref = useRef(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
|
||||||
const k = (e) => { if (e.key === "Escape") onClose(); };
|
|
||||||
document.addEventListener("mousedown", h);
|
|
||||||
document.addEventListener("keydown", k);
|
|
||||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k); };
|
|
||||||
}, []);
|
|
||||||
if (!menu) return null;
|
|
||||||
const x = Math.min(menu.x, window.innerWidth - 270);
|
|
||||||
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60));
|
|
||||||
return (
|
|
||||||
<div className="ctx" ref={ref} style={{ left: x, top: y }}>
|
|
||||||
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
|
||||||
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
|
||||||
<div key={i} className={"ctx-item" + (it.primary ? " primary" : "")}
|
|
||||||
onClick={() => { it.onClick(); onClose(); }}>
|
|
||||||
<span className="ic">{it.icon}</span>
|
|
||||||
<span>{it.label}</span>
|
|
||||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toasts({ toasts }) {
|
|
||||||
return (
|
|
||||||
<div className="toast-wrap">
|
|
||||||
{toasts.map((t) => (
|
|
||||||
<div key={t.id} className="toast">
|
|
||||||
{Icon.copy({ style: { color: "var(--accent)" } })}
|
|
||||||
<span className="tt">{t.title}</span>
|
|
||||||
{t.ref && <span className="tref">{t.ref}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PassPopup({ x, y, refStr, onConfirm, onCancel }) {
|
|
||||||
const [text, setText] = useState("");
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
const boxRef = useRef(null);
|
|
||||||
useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
|
|
||||||
useEffect(() => {
|
|
||||||
const h = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) onCancel(); };
|
|
||||||
const k = (e) => { if (e.key === "Escape") { e.preventDefault(); onCancel(); } };
|
|
||||||
document.addEventListener("mousedown", h);
|
|
||||||
document.addEventListener("keydown", k, true);
|
|
||||||
return () => { document.removeEventListener("mousedown", h); document.removeEventListener("keydown", k, true); };
|
|
||||||
}, []);
|
|
||||||
const left = Math.min(x, window.innerWidth - 360);
|
|
||||||
const top = Math.min(y + 6, window.innerHeight - 150);
|
|
||||||
const preview = (text.trim() ? text.trim() + " " : "") + refStr;
|
|
||||||
return (
|
|
||||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
|
||||||
<div className="pass-head">{Icon.spark({})}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
|
||||||
<input ref={inputRef} className="pass-input" value={text} spellCheck={false}
|
|
||||||
placeholder="Add a note (optional)…"
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") { e.preventDefault(); onConfirm(text); }
|
|
||||||
else if (e.key === "Escape") { e.preventDefault(); onCancel(); }
|
|
||||||
}} />
|
|
||||||
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div>
|
|
||||||
<div className="pass-foot"><kbd>↵</kbd> insert into agent · <kbd>esc</kbd> cancel</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, { SearchModal, ContextMenu, Toasts, PassPopup, fuzzy });
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
|
|
||||||
|
|
||||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
let _lid = 0;
|
|
||||||
const lid = () => ++_lid;
|
|
||||||
|
|
||||||
function Terminal({ kind, seed }) {
|
|
||||||
const [lines, setLines] = useState(seed.lines);
|
|
||||||
const [mode, setMode] = useState(seed.mode);
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const bodyRef = useRef(null);
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
const hist = useRef([]);
|
|
||||||
const histIx = useRef(-1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = bodyRef.current;
|
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
}, [lines, busy]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (kind !== "agent") return;
|
|
||||||
const h = (e) => {
|
|
||||||
if (mode !== "agent") { pushMany(bootAgent()); setMode("agent"); }
|
|
||||||
// bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
|
|
||||||
setInput((prev) => {
|
|
||||||
const base = prev.replace(/\n+$/, "");
|
|
||||||
return (base ? base + "\n" : "") + e.detail + "\n";
|
|
||||||
});
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const el = inputRef.current;
|
|
||||||
if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener("agentPaste", h);
|
|
||||||
return () => window.removeEventListener("agentPaste", h);
|
|
||||||
}, [kind, mode]);
|
|
||||||
|
|
||||||
const push = (line) => setLines((p) => [...p, { id: lid(), ...line }]);
|
|
||||||
const pushMany = (arr) => setLines((p) => [...p, ...arr.map((l) => ({ id: lid(), ...l }))]);
|
|
||||||
|
|
||||||
async function runAgent(prompt) {
|
|
||||||
setBusy(true);
|
|
||||||
push({ kind: "agent-think", html: `<span class="ag">●</span> <span class="dim">thinking…</span>` });
|
|
||||||
await wait(520);
|
|
||||||
setLines((p) => p.slice(0, -1)); // drop the thinking line
|
|
||||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> I'll take a look at the relevant files first.` });
|
|
||||||
await wait(380);
|
|
||||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">read</span> <span class="fp">src/Http/Controller/UserController.php</span>` });
|
|
||||||
await wait(300);
|
|
||||||
push({ kind: "t", cls: "dim", html: ` <span class="tool">grep</span> <span class="dim">"balanceFor" → 2 matches</span>` });
|
|
||||||
await wait(420);
|
|
||||||
push({ kind: "t", cls: "", html: `<span class="ag">●</span> Adding the field and a fillable allow-list. Editing now.` });
|
|
||||||
await wait(360);
|
|
||||||
push({
|
|
||||||
kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
|
|
||||||
rows: [
|
|
||||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
|
||||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
|
||||||
{ cls: "add", t: "+ 'currency' => $user->currency," },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await wait(450);
|
|
||||||
push({ kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done.</span> Updated <span class="fp">UserController.php</span>. Run tests with <span class="fp">composer test</span>.` });
|
|
||||||
setBusy(false);
|
|
||||||
requestAnimationFrame(() => inputRef.current && inputRef.current.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
function shell(cmd) {
|
|
||||||
const [c, ...rest] = cmd.split(/\s+/);
|
|
||||||
const arg = rest.join(" ");
|
|
||||||
switch (c) {
|
|
||||||
case "": return;
|
|
||||||
case "claude":
|
|
||||||
pushMany(bootAgent());
|
|
||||||
setMode("agent");
|
|
||||||
return;
|
|
||||||
case "clear": setLines([]); return;
|
|
||||||
case "help":
|
|
||||||
pushMany([
|
|
||||||
{ kind: "t", cls: "dim", html: "commands: <span style='color:var(--fg-1)'>claude</span> ls pwd cat <file> git status git diff echo clear" },
|
|
||||||
]); return;
|
|
||||||
case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
|
|
||||||
case "ls":
|
|
||||||
push({ kind: "t", html: "<span class='fp'>config</span> <span class='fp'>public</span> <span class='fp'>scripts</span> <span class='fp'>src</span> <span class='fp'>templates</span> composer.json package.json README.md" });
|
|
||||||
return;
|
|
||||||
case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
|
|
||||||
case "git":
|
|
||||||
if (rest[0] === "status") {
|
|
||||||
pushMany([
|
|
||||||
{ kind: "t", cls: "dim", html: `On branch <span style='color:var(--fg-1)'>${PROJECT.branch}</span>` },
|
|
||||||
{ kind: "t", cls: "dim", html: "Changes to be committed:" },
|
|
||||||
...PROJECT.changes.map((ch) => ({
|
|
||||||
kind: "t",
|
|
||||||
html: ` <span style="color:var(--${ch.status === 'A' ? 'add' : ch.status === 'D' ? 'del' : 'mod'})">${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'}</span> <span class="fp">${ch.path}</span>`,
|
|
||||||
})),
|
|
||||||
]);
|
|
||||||
} else if (rest[0] === "diff") {
|
|
||||||
pushMany([
|
|
||||||
{ kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
|
|
||||||
{ kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
|
|
||||||
{ kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
|
|
||||||
]);
|
|
||||||
} else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
|
|
||||||
return;
|
|
||||||
case "cat": {
|
|
||||||
const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
|
|
||||||
if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
|
|
||||||
pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || " " })));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
const cmd = input.trim();
|
|
||||||
if (busy) return;
|
|
||||||
if (cmd) { hist.current.unshift(cmd); }
|
|
||||||
histIx.current = -1;
|
|
||||||
if (mode === "agent") {
|
|
||||||
if (cmd === "/exit" || cmd === "exit") {
|
|
||||||
push({ kind: "t", cls: "dim", html: "<span class='ag'>●</span> Session ended." });
|
|
||||||
setMode("shell"); setInput(""); return;
|
|
||||||
}
|
|
||||||
if (cmd === "/clear" || cmd === "clear") { setLines([]); setInput(""); return; }
|
|
||||||
push({ kind: "t", html: `<span class="ip ag">></span> ${HL.escapeHtml(cmd).replace(/\n/g, "<br> ") || " "}` });
|
|
||||||
setInput("");
|
|
||||||
if (cmd) runAgent(cmd);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
push({ kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> ${HL.escapeHtml(cmd) || " "}` });
|
|
||||||
setInput("");
|
|
||||||
shell(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(e) {
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
|
|
||||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.includes("\n")) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.key === "ArrowUp") { if (hist.current.length) { histIx.current = Math.min(histIx.current + 1, hist.current.length - 1); setInput(hist.current[histIx.current]); } }
|
|
||||||
else { if (histIx.current > 0) { histIx.current--; setInput(hist.current[histIx.current]); } else { histIx.current = -1; setInput(""); } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const live = mode === "agent";
|
|
||||||
return (
|
|
||||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => inputRef.current && inputRef.current.focus()}>
|
|
||||||
<div className="term-head">
|
|
||||||
<span className={"dot" + (live ? " live" : "")}></span>
|
|
||||||
<span className="lbl">{live ? "claude" : kind === "agent" ? "claude" : "zsh"}</span>
|
|
||||||
<span className="tag">{live ? "agent session" : "— bash · ~/console"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="term-body" ref={bodyRef}>
|
|
||||||
{lines.map((l) => {
|
|
||||||
if (l.kind === "card") {
|
|
||||||
return (
|
|
||||||
<div key={l.id} className="term-card">
|
|
||||||
<div className="ch">{Icon.diff({ width: 11, height: 11 })}<span>{l.ch}</span></div>
|
|
||||||
{l.rows.map((r, i) => (<div key={i} className={r.cls}>{r.t}</div>))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (l.kind === "welcome") {
|
|
||||||
return (
|
|
||||||
<div key={l.id} className="term-card" style={{ borderColor: "var(--accent-line)" }}>
|
|
||||||
<div style={{ color: "var(--accent)", fontWeight: 600 }}>◇ agent session</div>
|
|
||||||
<div className="dim" style={{ color: "var(--fg-2)", marginTop: 3 }}>{l.text}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <div key={l.id} className={"tline " + (l.cls || "")} dangerouslySetInnerHTML={{ __html: l.html }} />;
|
|
||||||
})}
|
|
||||||
{busy && <div className="tline dim"><span className="ag" style={{ color: "#c98bdb" }}>●</span> working<span className="cursor-blink" /></div>}
|
|
||||||
{!busy && (
|
|
||||||
<div className="term-input">
|
|
||||||
<span className={"ip" + (live ? " ag" : "")}>{live ? ">" : <span className="pfx">console <span style={{ color: "var(--fg-3)" }}>%</span></span>}</span>
|
|
||||||
<textarea ref={inputRef} className="term-ta" value={input} spellCheck={false} autoComplete="off"
|
|
||||||
rows={Math.min(8, Math.max(1, input.split("\n").length))}
|
|
||||||
placeholder={live ? "Ask the agent to change something…" : kind === "agent" ? "type 'claude' to start a session" : ""}
|
|
||||||
onChange={(e) => setInput(e.target.value)} onKeyDown={onKey} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bootAgent() {
|
|
||||||
return [
|
|
||||||
{ kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
|
||||||
{ kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function agentSeed() {
|
|
||||||
return {
|
|
||||||
mode: "agent",
|
|
||||||
lines: [
|
|
||||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> claude` },
|
|
||||||
{ id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
|
|
||||||
{ id: lid(), kind: "t", html: `<span class="ip ag">></span> add currency + name to the user payload and gate the fillable fields` },
|
|
||||||
{ id: lid(), kind: "t", html: `<span class="ag">●</span> Read <span class="fp">UserController.php</span>, edited the response array and <span class="fp">onlyFillable()</span>.` },
|
|
||||||
{
|
|
||||||
id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
|
|
||||||
rows: [
|
|
||||||
{ cls: "del", t: "- 'plan' => $user->plan," },
|
|
||||||
{ cls: "add", t: "+ 'plan' => $user->plan->value," },
|
|
||||||
{ cls: "add", t: "+ 'name' => $user->name," },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: lid(), kind: "t", cls: "ok", html: `<span class="ag">●</span> <span class="ok">Done</span> — updated <span class="fp">UserController.php</span>. Anything else?` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellSeed() {
|
|
||||||
return {
|
|
||||||
mode: "shell",
|
|
||||||
lines: [
|
|
||||||
{ id: lid(), kind: "t", html: `<span class="pfx">console</span> <span class="dim">%</span> git status -s` },
|
|
||||||
...PROJECT.changes.map((c) => ({
|
|
||||||
id: lid(), kind: "t",
|
|
||||||
html: `<span style="color:var(--${c.status === 'A' ? 'add' : c.status === 'D' ? 'del' : 'mod'})">${c.status} </span> <span class="fp">${c.path}</span>`,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.assign(window, { Terminal, agentSeed, shellSeed });
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */
|
|
||||||
:root {
|
|
||||||
--bg-0:#16171a; /* editor surface (deepest) */
|
|
||||||
--bg-1:#1a1c1f; /* terminals */
|
|
||||||
--bg-2:#1f2226; /* sidebars */
|
|
||||||
--bg-3:#23262b; /* headers / tabs strip */
|
|
||||||
--hover:#2a2e34;
|
|
||||||
--active:#313742;
|
|
||||||
--sel:#2b323d;
|
|
||||||
--border:#2a2d33;
|
|
||||||
--border-2:#34383f;
|
|
||||||
--fg-0:#e6e8ea;
|
|
||||||
--fg-1:#b4bac2;
|
|
||||||
--fg-2:#838a94;
|
|
||||||
--fg-3:#5d636c;
|
|
||||||
--accent:#4d8dff;
|
|
||||||
--accent-soft:rgba(77,141,255,0.16);
|
|
||||||
--accent-line:rgba(77,141,255,0.55);
|
|
||||||
--add:#5cbd6b;
|
|
||||||
--del:#e0696a;
|
|
||||||
--mod:#d8a85c;
|
|
||||||
--ren:#5aa6d6;
|
|
||||||
--add-bg:rgba(92,189,107,0.10);
|
|
||||||
--del-bg:rgba(224,105,106,0.10);
|
|
||||||
/* syntax */
|
|
||||||
--t-key:#c98bdb;
|
|
||||||
--t-str:#94c980;
|
|
||||||
--t-num:#e0a06a;
|
|
||||||
--t-fn:#6aa6f0;
|
|
||||||
--t-com:#5f656e;
|
|
||||||
--t-tag:#7fc6a0;
|
|
||||||
--t-attr:#d8b15c;
|
|
||||||
--t-punc:#9aa0a8;
|
|
||||||
--t-var:#e6e8ea;
|
|
||||||
--t-const:#e08b6a;
|
|
||||||
--t-prop:#6ec0c0;
|
|
||||||
--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
||||||
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing:border-box; }
|
|
||||||
html,body { margin:0; height:100%; }
|
|
||||||
body {
|
|
||||||
background:var(--bg-0); color:var(--fg-0);
|
|
||||||
font-family:var(--ui); font-size:13px;
|
|
||||||
overflow:hidden; -webkit-font-smoothing:antialiased;
|
|
||||||
}
|
|
||||||
#root { height:100vh; }
|
|
||||||
::selection { background:rgba(77,141,255,0.32); }
|
|
||||||
|
|
||||||
/* scrollbars */
|
|
||||||
::-webkit-scrollbar { width:11px; height:11px; }
|
|
||||||
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background:#4a505a; background-clip:content-box; }
|
|
||||||
::-webkit-scrollbar-corner { background:transparent; }
|
|
||||||
|
|
||||||
/* ============ shell ============ */
|
|
||||||
.app { display:flex; flex-direction:column; height:100vh; }
|
|
||||||
|
|
||||||
.titlebar {
|
|
||||||
height:36px; flex:0 0 36px; display:flex; align-items:center;
|
|
||||||
background:var(--bg-3); border-bottom:1px solid var(--border);
|
|
||||||
padding:0 12px; gap:14px; user-select:none;
|
|
||||||
}
|
|
||||||
.traffic { display:flex; gap:8px; }
|
|
||||||
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
|
||||||
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
|
||||||
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
|
||||||
.tb-title b { color:var(--fg-0); font-weight:600; }
|
|
||||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
|
|
||||||
.tb-crumb .seg{color:var(--fg-2);}
|
|
||||||
.tb-spacer { flex:1; }
|
|
||||||
.tb-actions { display:flex; gap:6px; align-items:center; }
|
|
||||||
.tb-btn {
|
|
||||||
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
|
|
||||||
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
|
||||||
}
|
|
||||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
|
||||||
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
|
||||||
|
|
||||||
.workbench { flex:1; display:flex; min-height:0; }
|
|
||||||
|
|
||||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
|
|
||||||
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; }
|
|
||||||
.col.right-col { background:var(--bg-1); }
|
|
||||||
|
|
||||||
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
|
|
||||||
.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
|
|
||||||
.splitter:hover::after, .splitter.drag::after { background:var(--accent-line); }
|
|
||||||
.splitter.h { cursor:row-resize; flex:0 0 5px; width:100%; }
|
|
||||||
|
|
||||||
/* panel header */
|
|
||||||
.phead {
|
|
||||||
height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px;
|
|
||||||
padding:0 10px 0 12px; font-size:10.5px; letter-spacing:.09em; text-transform:uppercase;
|
|
||||||
color:var(--fg-2); border-bottom:1px solid var(--border); user-select:none;
|
|
||||||
}
|
|
||||||
.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.02em; text-transform:none;
|
|
||||||
background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:16px; }
|
|
||||||
.phead .ico-btn { color:var(--fg-3); cursor:pointer; padding:2px; border-radius:4px; display:flex; }
|
|
||||||
.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
|
|
||||||
|
|
||||||
/* ============ git panel ============ */
|
|
||||||
.commit-box { padding:9px 10px; border-bottom:1px solid var(--border); display:flex; gap:7px; align-items:flex-start; }
|
|
||||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
|
||||||
.commit-input:focus { border-color:var(--accent-line); }
|
|
||||||
.commit-input::placeholder { color:var(--fg-3); }
|
|
||||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
|
||||||
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
|
|
||||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
|
||||||
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
|
||||||
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
|
||||||
.git-group .gc { color:var(--fg-3); }
|
|
||||||
.git-group .grp-act { margin-left:auto; display:flex; opacity:0; background:transparent; border:0; color:var(--fg-2); padding:2px; border-radius:4px; cursor:pointer; }
|
|
||||||
.git-group:hover .grp-act { opacity:1; }
|
|
||||||
.git-group .grp-act:hover { background:var(--hover); color:var(--fg-0); }
|
|
||||||
.git-empty { display:flex; flex-direction:column; align-items:center; gap:9px; padding:30px 16px; color:var(--fg-3); font-size:12px; text-align:center; }
|
|
||||||
.git-empty svg { color:var(--add); opacity:.7; }
|
|
||||||
.git-none { padding:5px 14px 9px; font-size:11.5px; color:var(--fg-3); }
|
|
||||||
.git-none .key { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
|
||||||
.git-divider { height:1px; background:var(--border); margin:8px 12px 2px; }
|
|
||||||
.git-row {
|
|
||||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
|
||||||
}
|
|
||||||
.git-row:hover { background:var(--hover); }
|
|
||||||
.git-row.active { background:var(--sel); }
|
|
||||||
.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
|
||||||
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
|
|
||||||
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
|
||||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.git-row.active .git-name { color:var(--fg-0); }
|
|
||||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
|
||||||
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
|
||||||
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
|
||||||
.git-row:hover .git-act { display:flex; }
|
|
||||||
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
|
||||||
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
|
|
||||||
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
|
|
||||||
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
|
||||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
|
||||||
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
|
||||||
|
|
||||||
/* ============ file tree ============ */
|
|
||||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
|
||||||
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
|
||||||
.tree-row:hover { background:var(--hover); }
|
|
||||||
.tree-row.active { background:var(--sel); }
|
|
||||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
|
||||||
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
|
|
||||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
|
||||||
.tree-row.folder .tree-label { color:var(--fg-1); }
|
|
||||||
.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; }
|
|
||||||
.tree-badge.M{color:var(--mod);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
|
|
||||||
|
|
||||||
/* file type monogram icon */
|
|
||||||
.ficon { width:15px; height:15px; flex:0 0 15px; border-radius:3.5px; display:inline-flex; align-items:center; justify-content:center;
|
|
||||||
font-family:var(--mono); font-size:7.5px; font-weight:700; color:#11131600; position:relative; }
|
|
||||||
.ficon span { color:#0c0d0f; font-size:7.5px; line-height:1; letter-spacing:-.3px; }
|
|
||||||
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
|
||||||
|
|
||||||
/* ============ editor ============ */
|
|
||||||
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
|
|
||||||
.tabs::-webkit-scrollbar { height:0; }
|
|
||||||
.tab {
|
|
||||||
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
|
|
||||||
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
|
|
||||||
background:var(--bg-3); position:relative; max-width:230px;
|
|
||||||
}
|
|
||||||
.tab:hover { background:#272b31; }
|
|
||||||
.tab.active { background:var(--bg-0); color:var(--fg-0); }
|
|
||||||
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
|
|
||||||
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
|
|
||||||
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
|
|
||||||
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
|
|
||||||
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
|
|
||||||
.tab.dirtyclose .tclose { display:none; }
|
|
||||||
.tab.dirtyclose:hover .tclose { display:flex; }
|
|
||||||
.tab.dirtyclose:hover .tdot { display:none; }
|
|
||||||
.tab.dirtyclose .tdot { display:block; }
|
|
||||||
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
|
|
||||||
|
|
||||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
|
||||||
.editor { flex:1; overflow:auto; font-family:var(--mono); font-size:13px; line-height:20px; padding:6px 0 40px; }
|
|
||||||
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
|
||||||
.ln-row.cursor { background:rgba(255,255,255,0.035); }
|
|
||||||
.ln-row.add { background:var(--add-bg); }
|
|
||||||
.ln-row.del { background:var(--del-bg); }
|
|
||||||
.ln-row.selrange { background:var(--accent-soft); }
|
|
||||||
.ln-gutter { flex:0 0 54px; width:54px; text-align:right; padding-right:14px; color:var(--fg-3); user-select:none; cursor:pointer; font-size:12px; }
|
|
||||||
.ln-row.cursor .ln-gutter { color:var(--fg-1); }
|
|
||||||
.ln-gutter:hover { color:var(--fg-1); }
|
|
||||||
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
|
||||||
.ln-row.add .ln-sign { color:var(--add); }
|
|
||||||
.ln-row.del .ln-sign { color:var(--del); }
|
|
||||||
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
|
||||||
.editor.diff .ln-code { padding-left:6px; }
|
|
||||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
|
||||||
.empty-ed .big { font-size:13px; }
|
|
||||||
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
|
|
||||||
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
|
||||||
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
|
||||||
|
|
||||||
.diff-bar { height:26px; flex:0 0 26px; display:flex; align-items:center; gap:12px; padding:0 14px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); }
|
|
||||||
.diff-bar .a{color:var(--add);font-family:var(--mono);} .diff-bar .d{color:var(--del);font-family:var(--mono);}
|
|
||||||
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
|
|
||||||
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
|
|
||||||
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
|
|
||||||
|
|
||||||
/* four-segment view control */
|
|
||||||
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:7px; overflow:hidden; }
|
|
||||||
.diff-bar .seg button { background:transparent; border:0; border-right:1px solid var(--border-2); color:var(--fg-2); font:inherit; font-size:11px; padding:3px 12px; cursor:pointer; display:flex; align-items:center; gap:6px; }
|
|
||||||
.diff-bar .seg button:last-child { border-right:0; }
|
|
||||||
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
|
||||||
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
|
||||||
.diff-bar .seg .split-btn svg { opacity:.85; }
|
|
||||||
|
|
||||||
/* gutter change bars (Original / Updated / Split) */
|
|
||||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
|
||||||
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
|
|
||||||
.ln-row.empty { background:repeating-linear-gradient(45deg, rgba(255,255,255,0.015) 0 7px, transparent 7px 14px); }
|
|
||||||
|
|
||||||
/* full-screen split */
|
|
||||||
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; animation:tin .12s ease-out; }
|
|
||||||
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:11px; padding:0 16px; background:var(--bg-3); border-bottom:1px solid var(--border); }
|
|
||||||
.split-head .sh-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); }
|
|
||||||
.split-head .git-stat { font-size:11px; }
|
|
||||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
|
||||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
|
||||||
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
|
||||||
.split-body { flex:1; display:flex; min-height:0; }
|
|
||||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
|
||||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
|
||||||
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
|
|
||||||
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
|
||||||
|
|
||||||
/* syntax token colors */
|
|
||||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
|
|
||||||
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
|
|
||||||
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
|
|
||||||
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
|
|
||||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
|
|
||||||
.ln-code .token.tag{color:var(--t-tag);}
|
|
||||||
.ln-code .token.attr-name{color:var(--t-attr);}
|
|
||||||
.ln-code .token.punctuation{color:var(--t-punc);}
|
|
||||||
.ln-code .token.operator{color:var(--t-punc);}
|
|
||||||
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
|
|
||||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
|
|
||||||
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
|
|
||||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
|
|
||||||
.ln-code .token.parameter{color:var(--fg-0);}
|
|
||||||
.ln-code .token.namespace{color:var(--fg-2);}
|
|
||||||
.ln-code .token.selector{color:var(--t-tag);}
|
|
||||||
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
|
|
||||||
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
|
|
||||||
|
|
||||||
/* ============ terminals (right column) ============ */
|
|
||||||
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
|
|
||||||
.term-head { height:28px; flex:0 0 28px; display:flex; align-items:center; gap:8px; padding:0 10px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
|
||||||
.term-head .dot { width:7px; height:7px; border-radius:50%; background:var(--fg-3); }
|
|
||||||
.term-head .dot.live { background:var(--add); box-shadow:0 0 0 0 rgba(92,189,107,.5); animation:pulse 2.2s infinite; }
|
|
||||||
@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,189,107,.45);} 70%{box-shadow:0 0 0 5px rgba(92,189,107,0);} 100%{box-shadow:0 0 0 0 rgba(92,189,107,0);} }
|
|
||||||
.term-head .lbl { color:var(--fg-1); font-family:var(--mono); }
|
|
||||||
.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-3); font-family:var(--mono); }
|
|
||||||
.term-body { flex:1; overflow:auto; padding:8px 12px 12px; font-family:var(--mono); font-size:12.5px; line-height:18px; cursor:text; }
|
|
||||||
.tline { white-space:pre-wrap; word-break:break-word; }
|
|
||||||
.tline.dim{color:var(--fg-3);} .tline.acc{color:var(--accent);} .tline.ok{color:var(--add);} .tline.warn{color:var(--mod);} .tline.err{color:var(--del);}
|
|
||||||
.tline .pfx { color:var(--accent); }
|
|
||||||
.tline .ag { color:#c98bdb; }
|
|
||||||
.tline .tool { color:var(--mod); }
|
|
||||||
.tline .fp { color:var(--t-prop); }
|
|
||||||
.term-card { border:1px solid var(--border-2); border-radius:7px; padding:7px 10px; margin:5px 0; background:rgba(255,255,255,0.02); }
|
|
||||||
.term-card .ch { color:var(--fg-2); font-size:11px; margin-bottom:4px; display:flex; gap:7px; align-items:center; min-width:0; }
|
|
||||||
.term-card .ch span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.term-card .add,.term-card .del { white-space:pre-wrap; word-break:break-word; line-height:17px; }
|
|
||||||
.term-card .add{color:var(--add);} .term-card .del{color:var(--del);}
|
|
||||||
.term-input { display:flex; align-items:center; gap:8px; }
|
|
||||||
.term-input .ip { color:var(--accent); }
|
|
||||||
.term-input .ip.ag { color:#c98bdb; }
|
|
||||||
.term-input input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; caret-color:var(--accent); }
|
|
||||||
.cursor-blink { display:inline-block; width:7px; height:14px; background:var(--accent); margin-left:1px; animation:blink 1.1s step-end infinite; vertical-align:-2px; }
|
|
||||||
@keyframes blink { 50%{opacity:0;} }
|
|
||||||
|
|
||||||
/* ============ overlays ============ */
|
|
||||||
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
|
|
||||||
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
|
|
||||||
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
|
||||||
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
|
|
||||||
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
|
|
||||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
|
||||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
|
||||||
.pres.sel { background:var(--accent-soft); }
|
|
||||||
.pres .pn { font-size:13px; color:var(--fg-0); }
|
|
||||||
.pres .pn b { color:var(--accent); font-weight:700; }
|
|
||||||
.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
|
|
||||||
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
|
|
||||||
|
|
||||||
/* combined search modal (content + files) */
|
|
||||||
.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
|
||||||
.search-cols { display:flex; min-height:0; }
|
|
||||||
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
|
|
||||||
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
|
|
||||||
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
|
|
||||||
.sc-right .sc-head { background:#1e2024; }
|
|
||||||
.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
|
|
||||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
|
||||||
.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
|
|
||||||
.fres:hover { background:var(--hover); }
|
|
||||||
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
|
||||||
.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
|
||||||
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
|
|
||||||
/* content search */
|
|
||||||
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
|
|
||||||
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
|
|
||||||
.sr-file:hover { color:var(--fg-0); }
|
|
||||||
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
|
||||||
.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
|
|
||||||
.sr-line:hover { background:var(--hover); }
|
|
||||||
.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
|
|
||||||
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
|
|
||||||
|
|
||||||
/* pass-on-to-agent inline popup */
|
|
||||||
.pass-pop { position:fixed; z-index:85; width:344px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:10px; box-shadow:0 18px 48px rgba(0,0,0,.55); padding:11px; animation:popin .12s ease-out; }
|
|
||||||
@keyframes popin { from { transform:translateY(6px); } }
|
|
||||||
.pass-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--fg-1); margin-bottom:9px; }
|
|
||||||
.pass-head svg { color:#c98bdb; }
|
|
||||||
.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 6px; }
|
|
||||||
.pass-input { width:100%; box-sizing:border-box; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:13px; padding:8px 10px; outline:none; }
|
|
||||||
.pass-input:focus { border-color:var(--accent-line); }
|
|
||||||
.pass-input::placeholder { color:var(--fg-3); }
|
|
||||||
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
|
|
||||||
.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
|
|
||||||
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
|
|
||||||
.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
|
||||||
|
|
||||||
/* terminal multi-line input */
|
|
||||||
.term-input { align-items:flex-start; }
|
|
||||||
.term-ta { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; line-height:18px; caret-color:var(--accent); resize:none; padding:0; margin:0; overflow:hidden; }
|
|
||||||
.term-ta::placeholder { color:var(--fg-3); }
|
|
||||||
|
|
||||||
/* context menu */.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
|
||||||
.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
|
|
||||||
.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); }
|
|
||||||
.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
|
||||||
.ctx-item.primary { color:var(--fg-0); }
|
|
||||||
.ctx-item.primary .ic { color:var(--accent); }
|
|
||||||
.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
|
|
||||||
.ctx-sep { height:1px; background:var(--border); margin:5px 6px; }
|
|
||||||
.ctx-note { padding:4px 11px 7px; font-size:10.5px; color:var(--fg-3); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
||||||
|
|
||||||
/* toast */
|
|
||||||
.toast-wrap { position:fixed; bottom:34px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
|
|
||||||
.toast { background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:9px; padding:9px 14px; box-shadow:0 12px 34px rgba(0,0,0,.45); display:flex; align-items:center; gap:11px; animation:tin .18s ease-out; }
|
|
||||||
@keyframes tin { from{opacity:0; transform:translateY(8px);} }
|
|
||||||
.toast .tt { font-size:12.5px; color:var(--fg-0); }
|
|
||||||
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
|
||||||
|
|
||||||
/* ============ status bar ============ */
|
|
||||||
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
|
||||||
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
|
|
||||||
.sb:hover { background:var(--hover); }
|
|
||||||
.sb.accent { background:var(--accent); color:#0c1320; }
|
|
||||||
.sb.accent:hover { background:#5d97ff; }
|
|
||||||
.sb.spacer { flex:1; }
|
|
||||||
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
|
|
||||||
.sb b { font-weight:600; color:var(--fg-1); }
|
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:4px;flex:none">
|
<div style="display:flex;align-items:center;gap:4px;flex:none">
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘A</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Fluid <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘L</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
|
||||||
@@ -558,7 +558,7 @@
|
|||||||
<div style="width:640px;background:#18202B;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
<div style="width:640px;background:#18202B;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||||
<div style="height:44px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 12px;border-bottom:1px solid #232C39">
|
<div style="height:44px;display:flex;align-items:center;justify-content:flex-end;gap:4px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Search <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘F</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Auto-fit <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘A</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Fluid <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘L</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#6C7783">Hidden <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘.</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Note <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:4px 6px">⌘N</span></span>
|
||||||
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
|
<span style="display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:2px;background:rgba(232,145,58,.10);font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Git <span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid rgba(232,145,58,.35);border-radius:2px;padding:4px 6px">⌘G</span></span>
|
||||||
@@ -566,7 +566,7 @@
|
|||||||
<sc-if value="{{ showTooltips }}" hint-placeholder-val="{{ true }}">
|
<sc-if value="{{ showTooltips }}" hint-placeholder-val="{{ true }}">
|
||||||
<div style="padding:14px 12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end">
|
<div style="padding:14px 12px;display:flex;flex-direction:column;gap:10px;align-items:flex-end">
|
||||||
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Hidden files: <span style="color:#E8913A">hidden</span> — dotfiles excluded from the tree and the search. Click to show.</div>
|
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Hidden files: <span style="color:#E8913A">hidden</span> — dotfiles excluded from the tree and the search. Click to show.</div>
|
||||||
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Auto-fit panels: <span style="color:#E8913A">on</span> — columns re-fit on resize and focus. Click to lock the current sizes.</div>
|
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Column widths: <span style="color:#E8913A">fluid</span> — the columns re-fit on resize and focus. Click to lock them.</div>
|
||||||
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Project note (.notes.txt) — kept next to this project.</div>
|
<div style="max-width:300px;background:#232C39;border:1px solid #3A424C;border-radius:2px;padding:8px 10px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#E4E7E6">Project note (.notes.txt) — kept next to this project.</div>
|
||||||
</div>
|
</div>
|
||||||
</sc-if>
|
</sc-if>
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ toolbar, right-aligned, gap 4px.
|
|||||||
Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill
|
Toolbar toggle: label Sans 12px + keycap. Off = label `#BAC0C0`, no fill. On = fill
|
||||||
`rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`.
|
`rgba(232,145,58,.10)`, label `#F4F5F4` 500, keycap border `rgba(232,145,58,.35)`.
|
||||||
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
|
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
|
||||||
Search ⌘F · Auto-fit ⌘A · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
|
Search ⌘F · Fluid ⌘L · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
|
||||||
|
|
||||||
**Source control column** (236px, `#18202B`, 1px right border)
|
**Source control column** (236px, `#18202B`, 1px right border)
|
||||||
Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder
|
Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder
|
||||||
@@ -291,7 +291,7 @@ with amber glyphs.
|
|||||||
|
|
||||||
**Tooltip** — `#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px,
|
**Tooltip** — `#232C39` surface, 1px `#3A424C`, radius 2, padding 8px 10px, max 300px,
|
||||||
Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the
|
Sans 12px/1.5 `#E4E7E6`. No arrow, no shadow. The current state is named inside the
|
||||||
sentence in amber ("Hidden files: *hidden* — …", "Auto-fit panels: *on* — …"), then one
|
sentence in amber ("Hidden files: *hidden* — …", "Column widths: *fluid* — …"), then one
|
||||||
sentence saying what a click does.
|
sentence saying what a click does.
|
||||||
|
|
||||||
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's
|
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's
|
||||||
|
|||||||
BIN
releases/Helder-0.1.0-arm64.dmg
Normal file
BIN
releases/Helder-0.1.0-arm64.dmg
Normal file
Binary file not shown.
BIN
releases/helder-win-v0.1.0.zip
Normal file
BIN
releases/helder-win-v0.1.0.zip
Normal file
Binary file not shown.
@@ -10,6 +10,7 @@ import { join } from 'node:path'
|
|||||||
* and FONT SIZE live here (as CSS vars), not in the JSON
|
* and FONT SIZE live here (as CSS vars), not in the JSON
|
||||||
* Effective value = config.json over config.default.json, merged key by key.
|
* Effective value = config.json over config.default.json, merged key by key.
|
||||||
*/
|
*/
|
||||||
|
/** View a changed file opens in. 'diff' is the full-screen side-by-side overlay. */
|
||||||
export type DiffMode = 'original' | 'updated' | 'diff'
|
export type DiffMode = 'original' | 'updated' | 'diff'
|
||||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||||
@@ -55,7 +56,7 @@ export interface HelderConfig {
|
|||||||
export const DEFAULTS: HelderConfig = {
|
export const DEFAULTS: HelderConfig = {
|
||||||
ai: { command: 'claude', autoLaunch: true },
|
ai: { command: 'claude', autoLaunch: true },
|
||||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
|
||||||
files: { exclude: [], followGitignore: false },
|
files: { exclude: [], followGitignore: false },
|
||||||
terminal: {
|
terminal: {
|
||||||
shell: null,
|
shell: null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
||||||
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
||||||
|
|
||||||
@@ -26,10 +27,22 @@ let fatalDialogOpen = false
|
|||||||
* native crashes, and the log file should exist before anything can fail. */
|
* native crashes, and the log file should exist before anything can fail. */
|
||||||
export function initDiagnostics(isDev: boolean): void {
|
export function initDiagnostics(isDev: boolean): void {
|
||||||
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
||||||
// set before we ask for the path or the folder is called "Electron".
|
// set first or the folder is called "Electron". The name later becomes the
|
||||||
|
// project's own, hence the paths are read here and pinned to "Helder".
|
||||||
app.setName('Helder')
|
app.setName('Helder')
|
||||||
|
let pinErr: unknown = null
|
||||||
|
try {
|
||||||
|
for (const name of ['userData', 'logs'] as const) {
|
||||||
|
const dir = app.getPath(name)
|
||||||
|
mkdirSync(dir, { recursive: true }) // setPath throws on a directory that does not exist yet
|
||||||
|
app.setPath(name, dir)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pinErr = e // startup must survive this; the logger does not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
initLogger({ dir: app.getPath('logs'), mirror: isDev })
|
initLogger({ dir: app.getPath('logs'), mirror: isDev })
|
||||||
|
if (pinErr) logger.warn('session', 'could not pin the app paths — a rename may move them', { err: formatErr(pinErr) })
|
||||||
|
|
||||||
// Native minidumps for crashes no JS handler can see. Local only — nothing is
|
// Native minidumps for crashes no JS handler can see. Local only — nothing is
|
||||||
// uploaded anywhere (there is no server, and this is a personal tool).
|
// uploaded anywhere (there is no server, and this is a personal tool).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFileSync, statSync } from 'node:fs'
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||||
import { watch, type FSWatcher } from 'chokidar'
|
import { watch, type FSWatcher } from 'chokidar'
|
||||||
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
import { addRecentProject, getName, getProjectTitle, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
||||||
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from './fs-service'
|
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from './fs-service'
|
||||||
import { commit, discard, load, push, stage, unstage } from './git-service'
|
import { commit, discard, load, push, stage, unstage } from './git-service'
|
||||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
@@ -200,12 +200,12 @@ function buildAppMenu(): Menu {
|
|||||||
/**
|
/**
|
||||||
* macOS reads the Dock tile's name from the bundle's CFBundleName at launch, so
|
* macOS reads the Dock tile's name from the bundle's CFBundleName at launch, so
|
||||||
* every Helder process shows the same "Helder" tooltip — `app.setName()` moves
|
* every Helder process shows the same "Helder" tooltip — `app.setName()` moves
|
||||||
* the menu-bar name and the paths, but never the Dock label. The open project is
|
* the menu-bar name, but never the Dock label. The open project is
|
||||||
* therefore named in the Dock *menu* instead: a disabled first item, so a
|
* therefore named in the Dock *menu* instead: a disabled first item, so a
|
||||||
* right-click tells the two tiles apart.
|
* right-click tells the two tiles apart.
|
||||||
*/
|
*/
|
||||||
function buildDockMenu(): Menu {
|
function buildDockMenu(): Menu {
|
||||||
const name = getName()
|
const name = getRoot() ? getProjectTitle() : ''
|
||||||
return Menu.buildFromTemplate([
|
return Menu.buildFromTemplate([
|
||||||
...(name ? [{ label: name, enabled: false }, { type: 'separator' as const }] : []),
|
...(name ? [{ label: name, enabled: false }, { type: 'separator' as const }] : []),
|
||||||
{ label: 'New Window', click: () => spawnInstance() },
|
{ label: 'New Window', click: () => spawnInstance() },
|
||||||
@@ -357,11 +357,13 @@ function registerIpc(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The window title and the Dock menu both carry the open project's folder name
|
/** Window title, app name and Dock menu all carry the open project's name. The
|
||||||
* (the title falls back to the app name when nothing is open). Push both after a
|
* menu is rebuilt because the appMenu role items (About, Hide, Quit) read
|
||||||
* project change. */
|
* `app.name` once, at build time. */
|
||||||
function syncProjectChrome(): void {
|
function syncProjectChrome(): void {
|
||||||
const title = getName() || 'Helder'
|
const title = getProjectTitle()
|
||||||
|
app.setName(title)
|
||||||
|
Menu.setApplicationMenu(buildAppMenu())
|
||||||
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
|
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
|
||||||
app.dock?.setMenu(buildDockMenu())
|
app.dock?.setMenu(buildDockMenu())
|
||||||
}
|
}
|
||||||
@@ -374,7 +376,7 @@ function createWindow(): void {
|
|||||||
minHeight: 680,
|
minHeight: 680,
|
||||||
show: false,
|
show: false,
|
||||||
backgroundColor: '#16171a',
|
backgroundColor: '#16171a',
|
||||||
title: getName() || 'Helder',
|
title: getProjectTitle(),
|
||||||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||||||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -424,6 +426,7 @@ app.whenReady().then(async () => {
|
|||||||
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
|
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
|
||||||
if (initialRoot) {
|
if (initialRoot) {
|
||||||
await resolveConfig(initialRoot)
|
await resolveConfig(initialRoot)
|
||||||
|
syncProjectChrome()
|
||||||
await addRecentProject(initialRoot)
|
await addRecentProject(initialRoot)
|
||||||
startWatcher()
|
startWatcher()
|
||||||
startConfigWatcher()
|
startConfigWatcher()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { basename, join, resolve } from 'node:path'
|
import { basename, join, resolve } from 'node:path'
|
||||||
import { existsSync, statSync } from 'node:fs'
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||||
import { readFile, writeFile } from 'node:fs/promises'
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
import { app, dialog, BrowserWindow } from 'electron'
|
import { app, dialog, BrowserWindow } from 'electron'
|
||||||
|
import { formatErr, logger } from './logger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One project per window. The root is resolved (in order) from $HELDER_PROJECT or
|
* One project per window. The root is resolved (in order) from $HELDER_PROJECT or
|
||||||
@@ -34,6 +35,31 @@ export function getName(): string {
|
|||||||
return root ? basename(root) || root : ''
|
return root ? basename(root) || root : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name this process shows in the macOS menu bar: `APP_NAME` from the
|
||||||
|
* project's .env, else the folder name. Kept apart from `getName()`, which stays
|
||||||
|
* the folder name for recents and the renderer.
|
||||||
|
*/
|
||||||
|
export function getProjectTitle(): string {
|
||||||
|
if (!root) return 'Helder'
|
||||||
|
try {
|
||||||
|
let raw: string | null = null
|
||||||
|
for (const line of readFileSync(join(root, '.env'), 'utf8').split(/\r?\n/)) {
|
||||||
|
if (line.trimStart().startsWith('#')) continue
|
||||||
|
const m = /^\s*APP_NAME\s*=(.*)$/.exec(line)
|
||||||
|
if (m) raw = m[1]
|
||||||
|
}
|
||||||
|
const value = (raw ?? '').trim().replace(/^(['"])([\s\S]*)\1$/, '$2').trim()
|
||||||
|
if (value) return value
|
||||||
|
} catch (e) {
|
||||||
|
// A project without a .env is the normal case, not a fault.
|
||||||
|
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
|
||||||
|
logger.warn('project', '.env unreadable — using the folder name', { err: formatErr(e) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getName()
|
||||||
|
}
|
||||||
|
|
||||||
/** Point the window at a project root (absolute) and remember it in recents. */
|
/** Point the window at a project root (absolute) and remember it in recents. */
|
||||||
export function setRoot(next: string): void {
|
export function setRoot(next: string): void {
|
||||||
root = resolve(next)
|
root = resolve(next)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* 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, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FileTree, GitPanel, Icon, Tip, groupByDir } 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, OriginalPeek, SplitView } from './editor'
|
||||||
import type { Cursor, Mode, Selection } from './editor'
|
import type { Cursor, Mode, PeekInfo, Selection } from './editor'
|
||||||
import { Terminal, lid } from './terminals'
|
import { Terminal, lid } from './terminals'
|
||||||
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
|
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
|
||||||
import { ProjectLauncher } from './launcher'
|
import { ProjectLauncher } from './launcher'
|
||||||
@@ -17,6 +17,24 @@ import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
|||||||
|
|
||||||
const NO_COMMITTED = new Set<string>()
|
const NO_COMMITTED = new Set<string>()
|
||||||
|
|
||||||
|
/* Read-only panes carry no caret, so ⌘A has to name its own target: the pane
|
||||||
|
* that holds the current selection, else the file viewer. The gutter and the
|
||||||
|
* diff sign are user-select:none, so they stay out of the copied text. */
|
||||||
|
const SURFACES = '.editor, .md-view'
|
||||||
|
function selectAllSurface(): void {
|
||||||
|
const node = window.getSelection()?.anchorNode ?? null
|
||||||
|
const from = node && (node.nodeType === 1 ? (node as HTMLElement) : node.parentElement)
|
||||||
|
const el = from?.closest(SURFACES)
|
||||||
|
?? document.querySelector('.split-overlay .editor')
|
||||||
|
?? document.querySelector('.editor-wrap .editor, .editor-wrap .md-view')
|
||||||
|
if (!el) return
|
||||||
|
const range = document.createRange()
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
const sel = window.getSelection()
|
||||||
|
sel?.removeAllRanges()
|
||||||
|
sel?.addRange(range)
|
||||||
|
}
|
||||||
|
|
||||||
function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement {
|
function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onDelta: (dx: number, dy: number) => void }): React.ReactElement {
|
||||||
const [drag, setDrag] = useState(false)
|
const [drag, setDrag] = useState(false)
|
||||||
function down(e: React.MouseEvent): void {
|
function down(e: React.MouseEvent): void {
|
||||||
@@ -48,7 +66,7 @@ function RightColumn({ width, active, onFocus }: { width: number; active: boolea
|
|||||||
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
|
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
|
<div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 1 ' + width + 'px' }} onMouseDownCapture={onFocus}>
|
||||||
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
|
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
|
||||||
<Terminal kind="agent" />
|
<Terminal kind="agent" />
|
||||||
@@ -78,8 +96,8 @@ export function App(): React.ReactElement {
|
|||||||
|
|
||||||
const [active, setActive] = useState<string | null>(null)
|
const [active, setActive] = useState<string | null>(null)
|
||||||
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
|
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
|
||||||
// Which git row opened each tab. Only Diff and Split follow it: Original and
|
// Which git row opened each tab. Only Diff follows it: Original and Actual
|
||||||
// Actual always show HEAD and the file on disk.
|
// always show HEAD and the file on disk.
|
||||||
const [tabSide, setTabSide] = useState<Record<string, DiffSide>>({})
|
const [tabSide, setTabSide] = useState<Record<string, DiffSide>>({})
|
||||||
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
|
||||||
const [cursor, setCursor] = useState<Cursor | null>(null)
|
const [cursor, setCursor] = useState<Cursor | null>(null)
|
||||||
@@ -96,7 +114,7 @@ export function App(): React.ReactElement {
|
|||||||
const [histInitSel, setHistInitSel] = useState(0)
|
const [histInitSel, setHistInitSel] = useState(0)
|
||||||
const [menu, setMenu] = useState<Menu | null>(null)
|
const [menu, setMenu] = useState<Menu | null>(null)
|
||||||
const [toasts, setToasts] = useState<Toast[]>([])
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
const [splitFor, setSplitFor] = useState<string | null>(null)
|
const [peek, setPeek] = useState<PeekInfo | null>(null)
|
||||||
const [commitMsg, setCommitMsg] = useState('')
|
const [commitMsg, setCommitMsg] = useState('')
|
||||||
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
|
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
|
||||||
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
|
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
|
||||||
@@ -188,6 +206,8 @@ export function App(): React.ReactElement {
|
|||||||
// focus agent/terminal → Editor 20% / Right 50%
|
// focus agent/terminal → Editor 20% / Right 50%
|
||||||
// Re-applied on resize + focus change; dragging still works in between.
|
// Re-applied on resize + focus change; dragging still works in between.
|
||||||
const FOCUS_RESIZE_BELOW = 1600
|
const FOCUS_RESIZE_BELOW = 1600
|
||||||
|
const SPLITTER_W = 1 // .splitter is a 1px flex track
|
||||||
|
const MIN_EDITOR_W = 280 // Col C never drops below this
|
||||||
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
|
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
|
||||||
// Which column currently has focus — drives the active-panel tint and keyboard
|
// Which column currently has focus — drives the active-panel tint and keyboard
|
||||||
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
|
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
|
||||||
@@ -200,7 +220,7 @@ export function App(): React.ReactElement {
|
|||||||
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
|
// Auto panel management: re-fit columns on resize/focus. Manually dragging a
|
||||||
// splitter switches it off (the user took control); the title-bar toggle
|
// splitter switches it off (the user took control); the title-bar toggle
|
||||||
// turns it back on (and immediately re-fits).
|
// turns it back on (and immediately re-fits).
|
||||||
const [autoResize, setAutoResize] = useState(true)
|
const [fluid, setFluid] = useState(true)
|
||||||
const [showHidden, setShowHidden] = useState(false)
|
const [showHidden, setShowHidden] = useState(false)
|
||||||
// Col A (Git) visibility. On by default; the title-bar GIT toggle hides the
|
// Col A (Git) visibility. On by default; the title-bar GIT toggle hides the
|
||||||
// whole column and its splitter, and the editor takes the free width.
|
// whole column and its splitter, and the editor takes the free width.
|
||||||
@@ -209,7 +229,7 @@ export function App(): React.ReactElement {
|
|||||||
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
|
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
|
||||||
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
|
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoResize) return
|
if (!fluid) return
|
||||||
function apply(): void {
|
function apply(): void {
|
||||||
const w = window.innerWidth
|
const w = window.innerWidth
|
||||||
if (w >= FOCUS_RESIZE_BELOW) {
|
if (w >= FOCUS_RESIZE_BELOW) {
|
||||||
@@ -226,7 +246,29 @@ export function App(): React.ReactElement {
|
|||||||
apply()
|
apply()
|
||||||
window.addEventListener('resize', apply)
|
window.addEventListener('resize', apply)
|
||||||
return () => window.removeEventListener('resize', apply)
|
return () => window.removeEventListener('resize', apply)
|
||||||
}, [focusZone, autoResize])
|
}, [focusZone, fluid])
|
||||||
|
|
||||||
|
// Keep the stored widths inside the window. Col A, B and D are pixel tracks and
|
||||||
|
// only Col C absorbs the remainder, so a total wider than the window leaves the
|
||||||
|
// state disagreeing with what CSS shows (the columns carry flex-shrink 1 as the
|
||||||
|
// net) and the next splitter drag jumps. The budget reads window.innerWidth
|
||||||
|
// live, never a state copy: a resize sets the widths and the window size in the
|
||||||
|
// same batch, and a stale copy would scale the new widths against the old size.
|
||||||
|
const [winBump, bumpWin] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = (): void => bumpWin((n) => n + 1)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
return () => window.removeEventListener('resize', onResize)
|
||||||
|
}, [])
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const budget = window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W - MIN_EDITOR_W
|
||||||
|
const total = (showGit ? gitW : 0) + treeW + rightW
|
||||||
|
if (total <= budget || total <= 0) return
|
||||||
|
const k = Math.max(0, budget) / total
|
||||||
|
if (showGit) setGitW((w) => Math.floor(w * k))
|
||||||
|
setTreeW((w) => Math.floor(w * k))
|
||||||
|
setRightW((w) => Math.floor(w * k))
|
||||||
|
}, [winBump, showGit, gitW, treeW, rightW])
|
||||||
|
|
||||||
// A hidden Col A must not keep the focus ring or the arrow-key cursor.
|
// A hidden Col A must not keep the focus ring or the arrow-key cursor.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -498,7 +540,6 @@ export function App(): React.ReactElement {
|
|||||||
setTabSide(remapKeys)
|
setTabSide(remapKeys)
|
||||||
setOpenDirs((d) => new Set([...d].map(remap)))
|
setOpenDirs((d) => new Set([...d].map(remap)))
|
||||||
setActive((a) => (a ? remap(a) : a))
|
setActive((a) => (a ? remap(a) : a))
|
||||||
setSplitFor((s) => (s ? remap(s) : s))
|
|
||||||
setCursor((c) => (c ? { ...c, path: remap(c.path) } : c))
|
setCursor((c) => (c ? { ...c, path: remap(c.path) } : c))
|
||||||
actions.refresh()
|
actions.refresh()
|
||||||
toast(isDir ? 'Renamed folder' : 'Renamed file', next)
|
toast(isDir ? 'Renamed folder' : 'Renamed file', next)
|
||||||
@@ -514,6 +555,18 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultMode: Mode = proj.config.git.defaultDiffMode
|
const defaultMode: Mode = proj.config.git.defaultDiffMode
|
||||||
|
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||||
|
// Which half the open tab shows. Also lights up the matching git row.
|
||||||
|
const activeSide: DiffSide | null = (active && tabSide[active]) || null
|
||||||
|
/* Diff is a full-screen overlay, so Esc has to put the pane back where it was.
|
||||||
|
* Any entry point counts — the segment, a git row, ⌘M — so this just trails
|
||||||
|
* the last mode that was not Diff. */
|
||||||
|
const preDiff = useRef<Mode>('updated')
|
||||||
|
useEffect(() => { if (mode !== 'diff') preDiff.current = mode }, [mode])
|
||||||
|
const splitOpen = !!active && mode === 'diff' && !!proj.diffs[active] && !HL.isImage(active)
|
||||||
|
function closeSplit(): void {
|
||||||
|
if (active) setTabMode((m) => ({ ...m, [active]: preDiff.current }))
|
||||||
|
}
|
||||||
function stageGuarded(p: string): void {
|
function stageGuarded(p: string): void {
|
||||||
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
|
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
|
||||||
actions.stage(p)
|
actions.stage(p)
|
||||||
@@ -570,11 +623,11 @@ export function App(): React.ReactElement {
|
|||||||
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
|
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
|
||||||
reloadFromDisk(path)
|
reloadFromDisk(path)
|
||||||
setActive(path)
|
setActive(path)
|
||||||
// Git rows open the diff; explorer / recent-files open the updated view.
|
// A changed file lands in the Actual view. Only an explicit `diff` request
|
||||||
// Unchanged files only have the plain editable "code" view.
|
// opens the full-screen overlay. Unchanged files have the "code" view only.
|
||||||
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
|
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
|
||||||
setTabMode((m) => ({ ...m, [path]: openMode }))
|
setTabMode((m) => ({ ...m, [path]: openMode }))
|
||||||
// Remember which git row this came from, so Diff/Split show that half. An
|
// Remember which git row this came from, so Diff shows that half. An
|
||||||
// explorer click carries no side and falls back to the whole file.
|
// explorer click carries no side and falls back to the whole file.
|
||||||
setTabSide((m) => {
|
setTabSide((m) => {
|
||||||
const n = { ...m }
|
const n = { ...m }
|
||||||
@@ -589,7 +642,6 @@ export function App(): React.ReactElement {
|
|||||||
// so scroll its container to centre the target line. Line height is 20px with
|
// so scroll its container to centre the target line. Line height is 20px with
|
||||||
// a 6px top pad; retry across a few frames until the content has rendered (the
|
// a 6px top pad; retry across a few frames until the content has rendered (the
|
||||||
// file may still be loading for a freshly-opened unchanged file).
|
// file may still be loading for a freshly-opened unchanged file).
|
||||||
setSplitFor(null)
|
|
||||||
setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
|
setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
|
||||||
setCursor({ path, line: opts.line, col: 1 })
|
setCursor({ path, line: opts.line, col: 1 })
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
@@ -613,23 +665,6 @@ export function App(): React.ReactElement {
|
|||||||
requestAnimationFrame(place)
|
requestAnimationFrame(place)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the current file view (no tabs anymore — the recent-files list replaces
|
|
||||||
// them). The file stays in history; ⌘W just clears the editor after a dirty check.
|
|
||||||
async function closeTab(path: string): Promise<void> {
|
|
||||||
const buf = buffersRef.current[path]
|
|
||||||
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
|
|
||||||
if (dirtyNow) {
|
|
||||||
const bridge = window.helder
|
|
||||||
const choice = bridge
|
|
||||||
? await bridge.dialog.unsavedClose(path)
|
|
||||||
: (window.confirm(`Discard unsaved changes to ${path}?`) ? 'discard' : 'cancel')
|
|
||||||
if (choice === 'cancel') return
|
|
||||||
if (choice === 'save') writeToDisk(path, buf as string)
|
|
||||||
}
|
|
||||||
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
|
|
||||||
if (path === active) setActive(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- context menus ----
|
// ---- context menus ----
|
||||||
// Naming contract: every "Pass on …" action opens the input popup (so the user
|
// Naming contract: every "Pass on …" action opens the input popup (so the user
|
||||||
// can attach a note), and its "Copy …" twin sits directly below it. Pass first,
|
// can attach a note), and its "Copy …" twin sits directly below it. Pass first,
|
||||||
@@ -734,9 +769,9 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
|
// ↵ inside Git/Explorer: open the selected file, toggle a folder.
|
||||||
function openPanelSelection(): boolean {
|
function openPanelSelection(): boolean {
|
||||||
if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { diff: true, side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
|
if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
|
||||||
if (activePanel === 'tree' && treeSelItem) {
|
if (activePanel === 'tree' && treeSelItem) {
|
||||||
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
|
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
|
||||||
else openFile(treeSelItem.path)
|
else openFile(treeSelItem.path)
|
||||||
@@ -746,23 +781,21 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ⌘M cycles the active file through whatever views it supports: a changed file
|
// ⌘M cycles the active file through whatever views it supports: a changed file
|
||||||
// runs Updated → Original → Diff → Split (+ Preview for markdown); an unchanged
|
// runs Actual → Original → Diff (+ Preview for markdown); an unchanged markdown
|
||||||
// markdown file toggles Code ↔ Preview. Plain unchanged files have one view, so
|
// file toggles Code ↔ Preview. Plain unchanged files have one view, so there's
|
||||||
// there's nothing to cycle.
|
// nothing to cycle.
|
||||||
function cycleMode(): void {
|
function cycleMode(): void {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
reloadFromDisk(active)
|
reloadFromDisk(active)
|
||||||
const md = HL.langFor(active) === 'markdown'
|
const md = HL.langFor(active) === 'markdown'
|
||||||
const hasDiff = !!proj.diffs[active]
|
const hasDiff = !!proj.diffs[active]
|
||||||
const order: (Mode | 'split')[] = hasDiff
|
const order: Mode[] = hasDiff
|
||||||
? ['updated', 'original', 'diff', 'split', ...(md ? (['preview'] as const) : [])]
|
? ['updated', 'original', 'diff', ...(md ? (['preview'] as const) : [])]
|
||||||
: md ? ['code', 'preview'] : ['code']
|
: md ? ['code', 'preview'] : ['code']
|
||||||
if (order.length < 2) return
|
if (order.length < 2) return
|
||||||
const cur = splitFor === active ? 'split' : (tabMode[active] || (hasDiff ? defaultMode : 'code'))
|
const cur = tabMode[active] || (hasDiff ? defaultMode : 'code')
|
||||||
const idx = order.indexOf(cur)
|
const idx = order.indexOf(cur)
|
||||||
const next = order[(idx < 0 ? 0 : idx + 1) % order.length]
|
setTabMode((m) => ({ ...m, [active]: order[(idx < 0 ? 0 : idx + 1) % order.length] }))
|
||||||
if (next === 'split') setSplitFor(active)
|
|
||||||
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
|
|
||||||
}
|
}
|
||||||
function focusCommit(): void {
|
function focusCommit(): void {
|
||||||
document.querySelector<HTMLTextAreaElement>('.commit-input')?.focus()
|
document.querySelector<HTMLTextAreaElement>('.commit-input')?.focus()
|
||||||
@@ -867,7 +900,7 @@ export function App(): React.ReactElement {
|
|||||||
if (openPanelSelection()) { e.preventDefault(); return }
|
if (openPanelSelection()) { e.preventDefault(); return }
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (splitFor) setSplitFor(null)
|
if (splitOpen) closeSplit()
|
||||||
// Closing the note saves it there and then, rather than leaving the text
|
// Closing the note saves it there and then, rather than leaving the text
|
||||||
// to wait for the next blur.
|
// to wait for the next blur.
|
||||||
else if (overlay === 'notes') { setOverlay(null); saveNote() }
|
else if (overlay === 'notes') { setOverlay(null); saveNote() }
|
||||||
@@ -898,9 +931,6 @@ export function App(): React.ReactElement {
|
|||||||
// ⌘N opens the project note.
|
// ⌘N opens the project note.
|
||||||
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
|
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
|
||||||
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
|
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
|
||||||
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
|
||||||
// ⌘D deletes the current file (with confirmation).
|
|
||||||
else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
|
|
||||||
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
||||||
// ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
|
// ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
|
||||||
// the current text selection to the agent (else native nav).
|
// the current text selection to the agent (else native nav).
|
||||||
@@ -919,10 +949,16 @@ export function App(): React.ReactElement {
|
|||||||
if (hasSelection()) return
|
if (hasSelection()) return
|
||||||
e.preventDefault(); focusCommit()
|
e.preventDefault(); focusCommit()
|
||||||
}
|
}
|
||||||
// ⌘A toggles auto-fit (but let native select-all run inside text fields).
|
// ⌘A selects all of one surface. A text field has its own select-all;
|
||||||
|
// outside one we build the range ourselves, because the browser default
|
||||||
|
// would mark every panel and label in the window.
|
||||||
else if (meta && e.key.toLowerCase() === 'a') {
|
else if (meta && e.key.toLowerCase() === 'a') {
|
||||||
if (inField) return
|
if (inField) return
|
||||||
e.preventDefault(); setAutoResize((v) => !v)
|
e.preventDefault(); selectAllSurface()
|
||||||
|
}
|
||||||
|
// ⌘L makes the columns fluid again, or locks them at their current width.
|
||||||
|
else if (meta && e.key.toLowerCase() === 'l') {
|
||||||
|
e.preventDefault(); setFluid((v) => !v)
|
||||||
}
|
}
|
||||||
// ⌘. toggles hidden files. Match on e.code so it fires regardless of layout.
|
// ⌘. toggles hidden files. Match on e.code so it fires regardless of layout.
|
||||||
else if (meta && e.code === 'Period') {
|
else if (meta && e.code === 'Period') {
|
||||||
@@ -935,7 +971,7 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
|
}, [active, splitOpen, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
|
||||||
|
|
||||||
// ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
|
// ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
|
||||||
// left columns: git status (A) + the file tree (B) via a full project reload,
|
// left columns: git status (A) + the file tree (B) via a full project reload,
|
||||||
@@ -989,10 +1025,6 @@ export function App(): React.ReactElement {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [proj.config.git.refreshInterval])
|
}, [proj.config.git.refreshInterval])
|
||||||
|
|
||||||
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
|
||||||
// Which half the open tab shows. Also lights up the matching git row.
|
|
||||||
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.
|
// 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 })
|
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
|
||||||
@@ -1025,11 +1057,11 @@ export function App(): React.ReactElement {
|
|||||||
Search <kbd>⌘F</kbd>
|
Search <kbd>⌘F</kbd>
|
||||||
</button>
|
</button>
|
||||||
</Tip>
|
</Tip>
|
||||||
<Tip text={autoResize
|
<Tip text={fluid
|
||||||
? <>Auto-fit panels: <b>on</b> — columns re-fit on resize and focus. Click to lock the current sizes.</>
|
? <>Column widths: <b>fluid</b> — the columns re-fit on resize and focus. Click to lock them.</>
|
||||||
: <>Auto-fit panels: <b>off</b> — the sizes are locked. Click to re-enable.</>}>
|
: <>Column widths: <b>locked</b> — the columns keep their size. Click to make them fluid.</>}>
|
||||||
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}>
|
<button className={'tb-btn tb-toggle' + (fluid ? ' on' : '')} onClick={() => setFluid((v) => !v)}>
|
||||||
Auto-fit <kbd>⌘A</kbd>
|
Fluid <kbd>⌘L</kbd>
|
||||||
</button>
|
</button>
|
||||||
</Tip>
|
</Tip>
|
||||||
<Tip text={showHidden
|
<Tip text={showHidden
|
||||||
@@ -1072,7 +1104,7 @@ export function App(): React.ReactElement {
|
|||||||
{/* workbench */}
|
{/* workbench */}
|
||||||
<div className="workbench">
|
<div className="workbench">
|
||||||
{showGit && (<>
|
{showGit && (<>
|
||||||
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
|
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 1 ' + gitW + 'px' }}
|
||||||
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}
|
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}
|
||||||
onMouseOver={(e) => syncGitSel(e.target)}>
|
onMouseOver={(e) => syncGitSel(e.target)}>
|
||||||
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
|
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
|
||||||
@@ -1081,10 +1113,10 @@ export function App(): React.ReactElement {
|
|||||||
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} />
|
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} />
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
<Splitter onDelta={(dx) => { setFluid(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
<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 1 ' + 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>
|
<div className="phead">Explorer<span className="ct">{proj.name}</span></div>
|
||||||
@@ -1096,26 +1128,30 @@ export function App(): React.ReactElement {
|
|||||||
<div className="tree-body" />
|
<div className="tree-body" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
<Splitter onDelta={(dx) => { setFluid(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
|
||||||
|
|
||||||
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
|
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
|
||||||
<Editor active={active} mode={mode} side={activeSide}
|
<Editor active={active} mode={mode} side={activeSide}
|
||||||
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
|
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); reloadFromDisk(active) } }}
|
||||||
onContext={openMenu}
|
onContext={openMenu} onPeek={setPeek}
|
||||||
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
|
|
||||||
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
|
||||||
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
|
||||||
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
|
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
|
||||||
</div>
|
</div>
|
||||||
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
|
<Splitter onDelta={(dx) => { setFluid(false); setRightW((w) => {
|
||||||
// grow until the editor would drop below ~280px (rather than a fixed cap)
|
// grow until the editor would drop below ~280px (rather than a fixed cap)
|
||||||
const max = Math.max(280, window.innerWidth - (showGit ? gitW : 0) - treeW - 280)
|
const max = Math.max(MIN_EDITOR_W, window.innerWidth - (showGit ? 3 : 2) * SPLITTER_W
|
||||||
return clamp(w - dx, 280, max)
|
- (showGit ? gitW : 0) - treeW - MIN_EDITOR_W)
|
||||||
|
return clamp(w - dx, MIN_EDITOR_W, max)
|
||||||
}) }} />
|
}) }} />
|
||||||
|
|
||||||
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
|
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
|
||||||
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
|
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
|
||||||
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
|
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
|
||||||
|
|
||||||
|
{/* The hover reveal of Diff mode. It belongs to the body row, so it
|
||||||
|
covers the agent column and stops at the status bar. */}
|
||||||
|
{peek && <OriginalPeek {...peek} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* status bar — display only: nothing here is clickable */}
|
{/* status bar — display only: nothing here is clickable */}
|
||||||
@@ -1137,7 +1173,7 @@ export function App(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* overlays */}
|
{/* overlays */}
|
||||||
{splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
|
{splitOpen && <SplitView path={active as string} side={activeSide} onClose={closeSplit} 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}
|
||||||
onConfirm={(payload) => {
|
onConfirm={(payload) => {
|
||||||
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
|
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext,
|
|||||||
return (
|
return (
|
||||||
<div className={'git-row' + (staged ? ' staged' : '') + (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, { side })}
|
||||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
||||||
title={c.path}>
|
title={c.path}>
|
||||||
<span className={'git-stat ' + c.status}>{c.status}</span>
|
<span className={'git-stat ' + c.status}>{c.status}</span>
|
||||||
@@ -133,15 +133,18 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a change list into folder groups, keeping the incoming order. The
|
/** Split a change list into folder groups, in order of first appearance. The
|
||||||
* keyboard cursor walks the same order, so App must flatten with this too. */
|
* keyboard cursor walks the same order, so App must flatten with this too.
|
||||||
|
* git status is not path-sorted, so a folder comes back in separated runs. One
|
||||||
|
* group per folder keeps the header unique, and with it the React key. */
|
||||||
export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] {
|
export function groupByDir(list: Change[]): { dir: string; rows: Change[] }[] {
|
||||||
const out: { dir: string; rows: Change[] }[] = []
|
const out: { dir: string; rows: Change[] }[] = []
|
||||||
|
const byDir = new Map<string, { dir: string; rows: Change[] }>()
|
||||||
for (const c of list) {
|
for (const c of list) {
|
||||||
const dir = c.path.split('/').slice(0, -1).join('/')
|
const dir = c.path.split('/').slice(0, -1).join('/')
|
||||||
const last = out[out.length - 1]
|
const group = byDir.get(dir)
|
||||||
if (last && last.dir === dir) last.rows.push(c)
|
if (group) group.rows.push(c)
|
||||||
else out.push({ dir, rows: [c] })
|
else { const g = { dir, rows: [c] }; byDir.set(dir, g); out.push(g) }
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,105 @@
|
|||||||
/* Line-based LCS diff — the single source for the four view modes.
|
/* Line-based LCS diff — the single source for the view modes. Actual, Original
|
||||||
* Original / Updated / Diff / Split all derive from one (original, updated)
|
* and the side-by-side Diff all derive from one (original, updated) text pair
|
||||||
* text pair per changed file, whether that pair comes from the mock or from
|
* per changed file, whether that pair comes from the mock or from real
|
||||||
* real `git diff` (original = HEAD:path, updated = working tree). */
|
* `git diff` (original = HEAD:path, updated = working tree). */
|
||||||
import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
|
import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
|
||||||
|
|
||||||
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }
|
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }
|
||||||
|
|
||||||
export function buildDiff(origText: string, updText: string): Omit<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
|
/** Largest DP matrix we build: 4M cells = 16 MB of Int32. A region over the
|
||||||
const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n')
|
* budget is split on unique anchor lines instead, because the full n*m matrix
|
||||||
const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n')
|
* of a big file exhausts the heap and kills the renderer. */
|
||||||
const n = a.length, m = b.length
|
const MAX_CELLS = 4_000_000
|
||||||
|
|
||||||
|
type Pair = [number, number]
|
||||||
|
|
||||||
|
/** Longest strictly increasing subsequence over the b-index of each pair. */
|
||||||
|
function longestRun(pairs: Pair[]): Pair[] {
|
||||||
|
const tails: number[] = []
|
||||||
|
const prev = new Int32Array(pairs.length).fill(-1)
|
||||||
|
for (let k = 0; k < pairs.length; k++) {
|
||||||
|
const v = pairs[k][1]
|
||||||
|
let lo = 0, hi = tails.length
|
||||||
|
while (lo < hi) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (pairs[tails[mid]][1] < v) lo = mid + 1
|
||||||
|
else hi = mid
|
||||||
|
}
|
||||||
|
prev[k] = lo > 0 ? tails[lo - 1] : -1
|
||||||
|
tails[lo] = k
|
||||||
|
}
|
||||||
|
const out: Pair[] = []
|
||||||
|
let k = tails.length ? tails[tails.length - 1] : -1
|
||||||
|
while (k >= 0) { out.push(pairs[k]); k = prev[k] }
|
||||||
|
return out.reverse()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lines that appear exactly once on each side, in an order both sides share. */
|
||||||
|
function anchors(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number): Pair[] {
|
||||||
|
const countA = new Map<string, number>()
|
||||||
|
for (let i = a0; i < a1; i++) countA.set(a[i], (countA.get(a[i]) ?? 0) + 1)
|
||||||
|
const countB = new Map<string, number>(), atB = new Map<string, number>()
|
||||||
|
for (let j = b0; j < b1; j++) { countB.set(b[j], (countB.get(b[j]) ?? 0) + 1); atB.set(b[j], j) }
|
||||||
|
const pairs: Pair[] = []
|
||||||
|
for (let i = a0; i < a1; i++) {
|
||||||
|
if (countA.get(a[i]) !== 1 || countB.get(a[i]) !== 1) continue
|
||||||
|
pairs.push([i, atB.get(a[i])!])
|
||||||
|
}
|
||||||
|
return longestRun(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dpOps(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void {
|
||||||
|
const n = a1 - a0, m = b1 - b0
|
||||||
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
|
const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1))
|
||||||
for (let i = n - 1; i >= 0; i--)
|
for (let i = n - 1; i >= 0; i--)
|
||||||
for (let j = m - 1; j >= 0; j--)
|
for (let j = m - 1; j >= 0; j--)
|
||||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
dp[i][j] = a[a0 + i] === b[b0 + j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1])
|
||||||
|
|
||||||
const ops: Op[] = []
|
|
||||||
let i = 0, j = 0
|
let i = 0, j = 0
|
||||||
while (i < n && j < m) {
|
while (i < n && j < m) {
|
||||||
if (a[i] === b[j]) { ops.push({ t: 'same', a: i, b: j }); i++; j++ }
|
if (a[a0 + i] === b[b0 + j]) { ops.push({ t: 'same', a: a0 + i, b: b0 + j }); i++; j++ }
|
||||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: i }); i++ }
|
else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: 'del', a: a0 + i }); i++ }
|
||||||
else { ops.push({ t: 'add', b: j }); j++ }
|
else { ops.push({ t: 'add', b: b0 + j }); j++ }
|
||||||
}
|
}
|
||||||
while (i < n) { ops.push({ t: 'del', a: i++ }) }
|
while (i < n) ops.push({ t: 'del', a: a0 + i++ })
|
||||||
while (j < m) { ops.push({ t: 'add', b: j++ }) }
|
while (j < m) ops.push({ t: 'add', b: b0 + j++ })
|
||||||
|
}
|
||||||
|
|
||||||
|
function region(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number, ops: Op[]): void {
|
||||||
|
while (a0 < a1 && b0 < b1 && a[a0] === b[b0]) { ops.push({ t: 'same', a: a0, b: b0 }); a0++; b0++ }
|
||||||
|
const tail: Op[] = []
|
||||||
|
while (a1 > a0 && b1 > b0 && a[a1 - 1] === b[b1 - 1]) { a1--; b1--; tail.push({ t: 'same', a: a1, b: b1 }) }
|
||||||
|
|
||||||
|
const n = a1 - a0, m = b1 - b0
|
||||||
|
const replaceAll = (): void => {
|
||||||
|
for (let i = a0; i < a1; i++) ops.push({ t: 'del', a: i })
|
||||||
|
for (let j = b0; j < b1; j++) ops.push({ t: 'add', b: j })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n === 0 || m === 0) replaceAll()
|
||||||
|
else if (n * m <= MAX_CELLS) dpOps(a, b, a0, a1, b0, b1, ops)
|
||||||
|
else {
|
||||||
|
const pins = anchors(a, b, a0, a1, b0, b1)
|
||||||
|
if (pins.length === 0) replaceAll()
|
||||||
|
else {
|
||||||
|
let ai = a0, bi = b0
|
||||||
|
for (const [ax, bx] of pins) {
|
||||||
|
region(a, b, ai, ax, bi, bx, ops)
|
||||||
|
ops.push({ t: 'same', a: ax, b: bx })
|
||||||
|
ai = ax + 1; bi = bx + 1
|
||||||
|
}
|
||||||
|
region(a, b, ai, a1, bi, b1, ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let k = tail.length - 1; k >= 0; k--) ops.push(tail[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDiff(origText: string, updText: string): Omit<Diff, 'deleted' | 'added' | 'original' | 'updated'> {
|
||||||
|
const a = origText === '' ? [] : origText.replace(/\n$/, '').split('\n')
|
||||||
|
const b = updText === '' ? [] : updText.replace(/\n$/, '').split('\n')
|
||||||
|
|
||||||
|
const ops: Op[] = []
|
||||||
|
region(a, b, 0, a.length, 0, b.length, ops)
|
||||||
|
|
||||||
const rows: DiffRow[] = [], left: SideLine[] = [], right: SideLine[] = [], split: SplitRow[] = []
|
const rows: DiffRow[] = [], left: SideLine[] = [], right: SideLine[] = [], split: SplitRow[] = []
|
||||||
const delSet = new Set<number>(), addSet = new Set<number>()
|
const delSet = new Set<number>(), addSet = new Set<number>()
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
|
/* Editor: four view modes (Actual / Original / Diff / Preview) + line selection */
|
||||||
import React, { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { Diff, DiffSide, ViewLine } from './types'
|
import type { Diff, DiffSide, ViewLine } from './types'
|
||||||
import { rowId } from './types'
|
import { rowId } from './types'
|
||||||
import { useProject } from './project'
|
import { useProject } from './project'
|
||||||
import { HL } from './highlight'
|
import { HL } from './highlight'
|
||||||
import { isKnownSymbol, useSymbols } from './symbols'
|
import { isKnownSymbol, useSymbols } from './symbols'
|
||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
|
import { rlog } from './log'
|
||||||
import { FileIcon, Icon } from './components'
|
import { FileIcon, Icon } from './components'
|
||||||
import type { OnContext } from './components'
|
import type { OnContext } from './components'
|
||||||
|
|
||||||
@@ -57,6 +58,20 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
|||||||
return el || null
|
return el || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A line the file-level diff changed, as the editable buffer needs it. */
|
||||||
|
export interface ChangeMark {
|
||||||
|
line: number
|
||||||
|
/** The line itself is new or rewritten: tint the row, lift its number. */
|
||||||
|
tint: boolean
|
||||||
|
/** A removed run owns no line here, so its rule sits on an edge of this one. */
|
||||||
|
gap: 'above' | 'below' | null
|
||||||
|
}
|
||||||
|
/** A mark plus the geometry of its line, in `.ce-inner` coordinates. */
|
||||||
|
interface Band extends ChangeMark { top: number; height: number }
|
||||||
|
/** The line box of `.ce-pre` and its top padding — the unwrapped arithmetic. */
|
||||||
|
const LINE_H = 20
|
||||||
|
const PAD_TOP = 10
|
||||||
|
|
||||||
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
||||||
* scroll-synced line-number gutter. Live highlighting while typing.
|
* scroll-synced line-number gutter. Live highlighting while typing.
|
||||||
*
|
*
|
||||||
@@ -68,42 +83,48 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
|||||||
* the number is a CSS counter on `::before`, which keeps it on the first
|
* the number is a CSS counter on `::before`, which keeps it on the first
|
||||||
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
* visual row and leaves the folded rows unnumbered. Highlighting is then per
|
||||||
* line (like the diff views), so multi-line tokens don't carry over. */
|
* line (like the diff views), so multi-line tokens don't carry over. */
|
||||||
function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymbol }: {
|
function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, onContext, onSymbol }: {
|
||||||
path: string
|
path: string
|
||||||
text: string
|
text: string
|
||||||
lang: string | null
|
lang: string | null
|
||||||
wrap: boolean
|
wrap: boolean
|
||||||
/** Line to paint orange for a second after a jump. */
|
/** Line to paint orange for a second after a jump. */
|
||||||
flash: FlashLine | null
|
flash: FlashLine | null
|
||||||
|
/** Changed lines of this file, from the HEAD-vs-disk diff. */
|
||||||
|
marks: ChangeMark[]
|
||||||
|
/** The marked line under the pointer, with its top in the pane's viewport. */
|
||||||
|
onPick: (h: { line: number; top: number } | null) => void
|
||||||
onChange: (text: string) => void
|
onChange: (text: string) => void
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
onSymbol: OnSymbol
|
onSymbol: OnSymbol
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null)
|
||||||
const gutterRef = useRef<HTMLDivElement>(null)
|
const gutterRef = useRef<HTMLDivElement>(null)
|
||||||
const preRef = useRef<HTMLPreElement>(null)
|
const preRef = useRef<HTMLPreElement>(null)
|
||||||
const tabSize = useProject().config.editor.tabSize
|
const tabSize = useProject().config.editor.tabSize
|
||||||
const symVer = useSymbols() // re-highlight once the declared-name list lands
|
const symVer = useSymbols() // re-highlight once the declared-name list lands
|
||||||
const metaDown = useMetaKey()
|
const metaDown = useMetaKey()
|
||||||
|
const tinted = useMemo(() => new Set(marks.filter((m) => m.tint).map((m) => m.line)), [marks])
|
||||||
const html = useMemo(
|
const html = useMemo(
|
||||||
() => (wrap
|
() => (wrap
|
||||||
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
|
? text.split('\n').map((l, i) => `<div class="ce-line${tinted.has(i + 1) ? ' chg' : ''}">${HL.hlLine(l, lang)}</div>`).join('')
|
||||||
: HL.hlText(text, lang) + '\n'),
|
: HL.hlText(text, lang) + '\n'),
|
||||||
// symVer is not read here: HL marks the known class tokens from a module-level
|
// symVer is not read here: HL marks the known class tokens from a module-level
|
||||||
// set, so this is what recomputes the HTML when that set lands.
|
// set, so this is what recomputes the HTML when that set lands.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[text, lang, wrap, symVer],
|
[text, lang, wrap, symVer, tinted],
|
||||||
)
|
)
|
||||||
/* One text block, not one element per line: the font is monospace and every
|
/* One block of markup, not one element per line: the font is monospace and
|
||||||
* row is exactly 20px, so the numbers land on the same grid a list of divs
|
* every row is exactly 20px, so the numbers land on the same grid a list of
|
||||||
* gave — for one node instead of thousands on a long file. Nothing styles a
|
* divs gave — for one node instead of thousands on a long file. Only a
|
||||||
* single number today; the day something must, this goes back to spans. */
|
* changed number needs an element of its own. */
|
||||||
const gutter = useMemo(() => {
|
const gutter = useMemo(() => {
|
||||||
const n = text.split('\n').length
|
const n = text.split('\n').length
|
||||||
let out = '1'
|
const out: string[] = []
|
||||||
for (let i = 2; i <= n; i++) out += '\n' + i
|
for (let i = 1; i <= n; i++) out.push(tinted.has(i) ? `<span class="chg">${i}</span>` : String(i))
|
||||||
return out
|
return out.join('\n')
|
||||||
}, [text])
|
}, [text, tinted])
|
||||||
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
|
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
|
||||||
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
|
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
|
||||||
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
|
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
|
||||||
@@ -113,9 +134,57 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
|
|||||||
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
|
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
|
||||||
}, [flash, wrap, text])
|
}, [flash, wrap, text])
|
||||||
|
|
||||||
|
// A folded line is as tall as the rows it spans, so a width change moves every
|
||||||
|
// band under it. Nothing else here depends on the pane width.
|
||||||
|
const [paneW, setPaneW] = useState(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!wrap || !el || typeof ResizeObserver === 'undefined') return
|
||||||
|
const ro = new ResizeObserver(() => setPaneW(el.clientWidth))
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [wrap])
|
||||||
|
/* The change bands. They go behind the transparent textarea, so each one needs
|
||||||
|
* the geometry of its line the way the flash band does: arithmetic while the
|
||||||
|
* buffer is one blob, measured once a line can fold. */
|
||||||
|
const [bands, setBands] = useState<Band[]>([])
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!marks.length) { setBands([]); return }
|
||||||
|
const kids = preRef.current?.children
|
||||||
|
setBands(marks.map((m) => {
|
||||||
|
const row = wrap ? (kids?.[m.line - 1] as HTMLElement | undefined) : undefined
|
||||||
|
return row
|
||||||
|
? { ...m, top: row.offsetTop, height: row.offsetHeight }
|
||||||
|
: { ...m, top: PAD_TOP + (m.line - 1) * LINE_H, height: LINE_H }
|
||||||
|
}))
|
||||||
|
}, [marks, wrap, text, paneW])
|
||||||
|
|
||||||
|
/* The reveal of the old lines. A click pins it, a second click on the same
|
||||||
|
* band or a click off every band drops it. The click lands on the textarea
|
||||||
|
* rather than on a row, so the line comes from the bands — the same geometry
|
||||||
|
* that drew the tint. */
|
||||||
|
const pickRef = useRef<number | null>(null)
|
||||||
|
function report(list: Band[], line: number | null): void {
|
||||||
|
const b = line == null ? undefined : list.find((x) => x.line === line)
|
||||||
|
pickRef.current = b ? b.line : null
|
||||||
|
onPick(b && scrollRef.current ? { line: b.line, top: b.top - scrollRef.current.scrollTop } : null)
|
||||||
|
}
|
||||||
|
function pickAt(e: React.MouseEvent): void {
|
||||||
|
if (!bands.length && pickRef.current == null) return
|
||||||
|
const box = innerRef.current?.getBoundingClientRect()
|
||||||
|
if (!box) return
|
||||||
|
const y = e.clientY - box.top
|
||||||
|
const hit = bands.find((b) => y >= b.top && y < b.top + b.height)
|
||||||
|
report(bands, hit && hit.line !== pickRef.current ? hit.line : null)
|
||||||
|
}
|
||||||
|
// An edit moves the bands under the pinned line; the panel follows them.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useLayoutEffect(() => { if (pickRef.current != null) report(bands, pickRef.current) }, [bands])
|
||||||
|
|
||||||
function onScroll(): void {
|
function onScroll(): void {
|
||||||
const s = scrollRef.current
|
const s = scrollRef.current
|
||||||
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
|
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
|
||||||
|
if (pickRef.current != null) report(bands, pickRef.current)
|
||||||
}
|
}
|
||||||
// The textarea is overflow-hidden under the scroller, so keep the caret line
|
// The textarea is overflow-hidden under the scroller, so keep the caret line
|
||||||
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
|
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
|
||||||
@@ -209,11 +278,19 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
|
|||||||
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
|
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
|
||||||
{!wrap && (
|
{!wrap && (
|
||||||
<div className="ce-gutterwrap">
|
<div className="ce-gutterwrap">
|
||||||
<div className="ce-gutter" ref={gutterRef}>{gutter}</div>
|
<div className="ce-gutter" ref={gutterRef} dangerouslySetInnerHTML={{ __html: gutter }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
|
{/* No marks, no pick: an unchanged file pays nothing for the reveal. */}
|
||||||
<div className="ce-inner">
|
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}
|
||||||
|
onClick={(e) => { if (e.metaKey || e.ctrlKey) handleTokenClick(e); else if (marks.length) pickAt(e) }}>
|
||||||
|
<div className="ce-inner" ref={innerRef}>
|
||||||
|
{bands.map((b) => (
|
||||||
|
<Fragment key={b.line}>
|
||||||
|
{b.tint && <div className="ce-band" style={{ top: b.top, height: b.height }} />}
|
||||||
|
{b.gap && <div className="ce-gaprule" style={{ top: b.gap === 'above' ? b.top : b.top + b.height - 2 }} />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
|
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
|
||||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||||
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
|
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
|
||||||
@@ -229,12 +306,41 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rendered-markdown preview: read-only, derived from the live buffer text. */
|
/* The file has no HEAD version. Shown by Original mode and by the picked
|
||||||
|
* overlay, so both say the same thing. */
|
||||||
|
function NoOriginal(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="empty-ed">
|
||||||
|
<div className="big" style={{ color: 'var(--add)' }}>No original version</div>
|
||||||
|
<div className="note">This file is new in the change.</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rendered-markdown preview: read-only, derived from the live buffer text.
|
||||||
|
* An image that the document names by a relative path is resolved against the
|
||||||
|
* document's own folder and then read over IPC, because the preview runs from
|
||||||
|
* the app's origin and cannot reach the project folder by itself. */
|
||||||
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
|
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
|
||||||
const html = useMemo(() => renderMarkdown(text), [text])
|
const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
|
||||||
|
const html = useMemo(() => renderMarkdown(text, dir), [text, dir])
|
||||||
|
const body = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const root = body.current
|
||||||
|
const bridge = window.helder
|
||||||
|
if (!root || !bridge) return
|
||||||
|
let alive = true
|
||||||
|
for (const img of Array.from(root.querySelectorAll<HTMLImageElement>('img[data-src]'))) {
|
||||||
|
const rel = img.dataset.src ?? ''
|
||||||
|
bridge.fs.imageDataUrl(rel)
|
||||||
|
.then((url) => { if (alive && url) img.src = url })
|
||||||
|
.catch((e) => rlog.warn('markdown', 'image read failed', { rel, err: String(e) }))
|
||||||
|
}
|
||||||
|
return () => { alive = false }
|
||||||
|
}, [html])
|
||||||
return (
|
return (
|
||||||
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
|
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
|
||||||
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
|
<div className="md-body" ref={body} dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -371,6 +477,7 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
|
|||||||
const cls = 'ln-row'
|
const cls = 'ln-row'
|
||||||
+ (l.row === 'add' ? ' add' : l.row === 'del' ? ' del' : '')
|
+ (l.row === 'add' ? ' add' : l.row === 'del' ? ' del' : '')
|
||||||
+ (l.row === 'bar-add' ? ' bar-add' : l.row === 'bar-del' ? ' bar-del' : '')
|
+ (l.row === 'bar-add' ? ' bar-add' : l.row === 'bar-del' ? ' bar-del' : '')
|
||||||
|
+ (l.gap === 'above' ? ' gap-above' : l.gap === 'below' ? ' gap-below' : '')
|
||||||
+ (no === curLine && !inSel && !l.row ? ' cursor' : '')
|
+ (no === curLine && !inSel && !l.row ? ' cursor' : '')
|
||||||
+ (inSel ? ' selrange' : '')
|
+ (inSel ? ' selrange' : '')
|
||||||
return (
|
return (
|
||||||
@@ -385,13 +492,65 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Build the line descriptors for a given mode. The caller picks which diff to
|
/** The original lines behind one current line: the range to mark amber, and the
|
||||||
* pass: Original and Actual always get the file-level HEAD-vs-disk pair, while
|
* one to line up with the picked row. */
|
||||||
* Diff gets the pair of the git row you clicked. */
|
interface PeekTarget {
|
||||||
|
anchor: number
|
||||||
|
from: number | null
|
||||||
|
to: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actual: the current file with the changed lines marked in place. Removals own
|
||||||
|
* no line here, so a run of them puts its rule on the edge of the line that took
|
||||||
|
* its place — unless that line is itself an added one, which already says the
|
||||||
|
* same thing in teal. The map is what the overlay reads. */
|
||||||
|
export function buildDiffView(diff: Diff): { lines: ViewLine[]; peek: Map<number, PeekTarget> } {
|
||||||
|
const peek = new Map<number, PeekTarget>()
|
||||||
|
const above = new Set<number>()
|
||||||
|
let tail: PeekTarget | null = null
|
||||||
|
let dels: number[] = []
|
||||||
|
let adds: number[] = []
|
||||||
|
let prevSame = 0
|
||||||
|
const flush = (nextNew: number | null): void => {
|
||||||
|
if (dels.length && adds.length) {
|
||||||
|
adds.forEach((no, k) => {
|
||||||
|
const old = dels[Math.min(k, dels.length - 1)]
|
||||||
|
peek.set(no, { anchor: old, from: old, to: old })
|
||||||
|
})
|
||||||
|
} else if (dels.length) {
|
||||||
|
const target: PeekTarget = { anchor: dels[0], from: dels[0], to: dels[dels.length - 1] }
|
||||||
|
if (nextNew == null) tail = target
|
||||||
|
else { peek.set(nextNew, target); above.add(nextNew) }
|
||||||
|
} else {
|
||||||
|
adds.forEach((no) => peek.set(no, { anchor: Math.max(1, prevSame), from: null, to: null }))
|
||||||
|
}
|
||||||
|
dels = []; adds = []
|
||||||
|
}
|
||||||
|
for (const r of diff.rows) {
|
||||||
|
if (r.sign === '-') dels.push(r.oldNo as number)
|
||||||
|
else if (r.sign === '+') adds.push(r.newNo as number)
|
||||||
|
else { flush(r.newNo); prevSame = r.oldNo as number }
|
||||||
|
}
|
||||||
|
flush(null)
|
||||||
|
|
||||||
|
// A run removed at the end of the file has no line after it, so its rule goes
|
||||||
|
// under the last one instead.
|
||||||
|
const last = diff.right.length ? diff.right[diff.right.length - 1].no : null
|
||||||
|
if (tail && last != null) peek.set(last, tail)
|
||||||
|
const lines: ViewLine[] = diff.right.map((l) => ({
|
||||||
|
no: l.no,
|
||||||
|
text: l.text,
|
||||||
|
row: l.mark === 'add' ? 'add' : null,
|
||||||
|
gap: above.has(l.no) ? 'above' : tail && l.no === last ? 'below' : null,
|
||||||
|
}))
|
||||||
|
return { lines, peek }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build the line descriptors for the read-only whole-file modes. Actual has its
|
||||||
|
* own builder above, because it also has to answer what each line used to be. */
|
||||||
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
|
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
|
||||||
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
|
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
|
||||||
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
|
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
|
||||||
if (mode === 'diff' && diff) return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === '+' ? 'add' : r.sign === '-' ? 'del' : null })), showSign: true }
|
|
||||||
// plain file
|
// plain file
|
||||||
const arr = (fileText || '').replace(/\n$/, '').split('\n')
|
const arr = (fileText || '').replace(/\n$/, '').split('\n')
|
||||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
|
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
|
||||||
@@ -408,15 +567,15 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
|
|||||||
return segs
|
return segs
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
|
export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
|
||||||
active: string | null
|
active: string | null
|
||||||
mode: Mode
|
mode: Mode
|
||||||
/** Which git row opened this tab. Only Diff and Split follow it. */
|
/** Which git row opened this tab. Only Diff follows it. */
|
||||||
side: DiffSide | null
|
side: DiffSide | null
|
||||||
setMode: (m: Mode) => void
|
setMode: (m: Mode) => void
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
onSplit: (path: string) => void
|
/** The original of the line under the pointer, for the overlay App renders. */
|
||||||
splitOpen: boolean
|
onPeek: (p: PeekInfo | null) => void
|
||||||
cursor: Cursor | null
|
cursor: Cursor | null
|
||||||
selection: Selection | null
|
selection: Selection | null
|
||||||
setCursor: (c: Cursor) => void
|
setCursor: (c: Cursor) => void
|
||||||
@@ -434,8 +593,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
|
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
|
||||||
// Original / Actual always show the whole file: HEAD vs disk.
|
// Original / Actual always show the whole file: HEAD vs disk.
|
||||||
const diff = tab ? PROJECT.diffs[tab.path] : null
|
const diff = tab ? PROJECT.diffs[tab.path] : null
|
||||||
// Diff / Split show the half you clicked in the git panel. A file with only
|
// Diff shows the half you clicked in the git panel. A file with only one row
|
||||||
// one row has an identical pair either way.
|
// has an identical pair either way.
|
||||||
const rowDiff = (tab && side ? PROJECT.rowDiffs[rowId(tab.path, side === 'staged')] : null) || diff
|
const rowDiff = (tab && side ? PROJECT.rowDiffs[rowId(tab.path, side === 'staged')] : null) || diff
|
||||||
const bothSides = !!tab && !!PROJECT.rowDiffs[rowId(tab.path, true)] && !!PROJECT.rowDiffs[rowId(tab.path, false)]
|
const bothSides = !!tab && !!PROJECT.rowDiffs[rowId(tab.path, true)] && !!PROJECT.rowDiffs[rowId(tab.path, false)]
|
||||||
const lang = tab ? HL.langFor(tab.path) : null
|
const lang = tab ? HL.langFor(tab.path) : null
|
||||||
@@ -443,7 +602,7 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
const isMarkdown = lang === 'markdown'
|
const isMarkdown = lang === 'markdown'
|
||||||
const segments = segmentsFor(hasDiff, isMarkdown)
|
const segments = segmentsFor(hasDiff, isMarkdown)
|
||||||
// Long lines fold instead of scrolling sideways. Default: Markdown only, where
|
// Long lines fold instead of scrolling sideways. Default: Markdown only, where
|
||||||
// a paragraph is one very long line. Split view is left out on purpose — its
|
// a paragraph is one very long line. The Diff view is left out on purpose — its
|
||||||
// two panes align row by row, and folding would pull them apart.
|
// two panes align row by row, and folding would pull them apart.
|
||||||
const wordWrap = PROJECT.config.editor.wordWrap
|
const wordWrap = PROJECT.config.editor.wordWrap
|
||||||
const wrap = wordWrap === 'on' || (wordWrap === 'markdown' && isMarkdown)
|
const wrap = wordWrap === 'on' || (wordWrap === 'markdown' && isMarkdown)
|
||||||
@@ -456,26 +615,70 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
|
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
|
||||||
else effMode = 'code'
|
else effMode = 'code'
|
||||||
|
|
||||||
// The diff actually on screen: the row pair for Diff, the whole file otherwise.
|
// Diff is the full-screen side-by-side view App lays over everything, so the
|
||||||
|
// pane behind it keeps showing the editable buffer.
|
||||||
|
const paneMode: Mode = effMode === 'diff' ? 'updated' : effMode
|
||||||
|
// The pair the bar counts: the clicked git row for Diff, the whole file otherwise.
|
||||||
const shown = effMode === 'diff' ? rowDiff : diff
|
const shown = effMode === 'diff' ? rowDiff : diff
|
||||||
|
/* Actual marks the changed lines and answers what each one replaced. Both come
|
||||||
|
* from the file-level pair, never from the git row: the buffer on screen is the
|
||||||
|
* file on disk. */
|
||||||
|
const diffView = useMemo(() => (paneMode === 'updated' && diff ? buildDiffView(diff) : null), [paneMode, diff])
|
||||||
|
const marks = useMemo<ChangeMark[]>(() => (diffView
|
||||||
|
? diffView.lines
|
||||||
|
.filter((l) => l.row === 'add' || l.gap)
|
||||||
|
.map((l) => ({ line: l.no as number, tint: l.row === 'add', gap: l.gap ?? null }))
|
||||||
|
: []), [diffView])
|
||||||
let built: { lines: ViewLine[]; showSign: boolean } | null = null
|
let built: { lines: ViewLine[]; showSign: boolean } | null = null
|
||||||
if (tab && effMode !== 'preview') {
|
if (tab && paneMode !== 'preview') {
|
||||||
if (hasDiff) built = buildLines(effMode, shown, PROJECT.files[tab.path])
|
built = hasDiff ? buildLines(paneMode, diff, PROJECT.files[tab.path]) : buildLines('code', null, PROJECT.files[tab.path])
|
||||||
else built = buildLines('code', null, PROJECT.files[tab.path])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// File-level status, taken from the whole-file diff rather than a single row:
|
// File-level status, taken from the whole-file diff rather than a single row:
|
||||||
// a file can be staged as modified and deleted on disk at the same time.
|
// a file can be staged as modified and deleted on disk at the same time.
|
||||||
const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status
|
const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status
|
||||||
const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified'
|
const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified'
|
||||||
const activeSeg = splitOpen ? 'split' : effMode
|
const activeSeg = effMode
|
||||||
// Keyed off the git status, not the line count: an empty file that still exists
|
// Keyed off the git status, not the line count: an empty file that still exists
|
||||||
// (a just-created one, or one emptied by hand) has zero lines too, and must get
|
// (a just-created one, or one emptied by hand) has zero lines too, and must get
|
||||||
// the editor rather than the "deleted" placeholder.
|
// the editor rather than the "deleted" placeholder.
|
||||||
const emptyUpdated = effMode === 'updated' && fileStatus === 'D'
|
const emptyUpdated = paneMode === 'updated' && fileStatus === 'D'
|
||||||
const emptyOriginal = effMode === 'original' && fileStatus === 'A'
|
const emptyOriginal = paneMode === 'original' && fileStatus === 'A'
|
||||||
// Editable in the live-buffer modes; Original/Diff/Preview stay read-only views.
|
// Editable in the live-buffer modes; Original/Preview stay read-only views.
|
||||||
const editable = effMode === 'code' || effMode === 'updated'
|
const editable = paneMode === 'code' || paneMode === 'updated'
|
||||||
|
|
||||||
|
/* The pinned reveal of Actual. CodeEditor owns the geometry, because over a
|
||||||
|
* textarea there is no row to read the pointer off; `top` is the picked
|
||||||
|
* line's offset inside the pane viewport, and the overlay aligns on it. */
|
||||||
|
const [pick, setPick] = useState<{ line: number; top: number } | null>(null)
|
||||||
|
const onPickLine = useCallback((h: { line: number; top: number } | null) => setPick(h), [])
|
||||||
|
useEffect(() => { setPick(null) }, [active, effMode, side])
|
||||||
|
/* A click anywhere but the code drops the panel. The editor's own clicks are
|
||||||
|
* already handled there, and the panel takes no pointer, so a click on it
|
||||||
|
* lands on the column below and counts as "somewhere else". */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pick) return
|
||||||
|
function away(e: MouseEvent): void {
|
||||||
|
if (!(e.target as HTMLElement | null)?.closest?.('.ce-scroll')) setPick(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', away, true)
|
||||||
|
return () => document.removeEventListener('mousedown', away, true)
|
||||||
|
}, [pick])
|
||||||
|
|
||||||
|
const target = pick && diffView ? diffView.peek.get(pick.line) ?? null : null
|
||||||
|
const peekLines = useMemo<ViewLine[]>(() => (target && diff
|
||||||
|
? diff.left.map((l) => ({
|
||||||
|
no: l.no,
|
||||||
|
text: l.text,
|
||||||
|
row: target.from != null && l.no >= target.from && l.no <= (target.to as number) ? 'del' : null,
|
||||||
|
}))
|
||||||
|
: []), [diff, target])
|
||||||
|
useEffect(() => {
|
||||||
|
onPeek(active && target && pick
|
||||||
|
? { path: active, lines: peekLines, lang, wrap, anchor: target.anchor, top: pick.top, empty: peekLines.length === 0 }
|
||||||
|
: null)
|
||||||
|
}, [active, target, pick, peekLines, lang, wrap, onPeek])
|
||||||
|
useEffect(() => () => onPeek(null), [onPeek])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
@@ -516,27 +719,22 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
{segments.map((s) => (
|
{segments.map((s) => (
|
||||||
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||||
))}
|
))}
|
||||||
{hasDiff && (
|
|
||||||
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
|
||||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg>
|
|
||||||
Split
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<ImageView path={tab.path} onContext={onContext} />
|
<ImageView path={tab.path} onContext={onContext} />
|
||||||
) : effMode === 'preview' ? (
|
) : paneMode === 'preview' ? (
|
||||||
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
|
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
|
||||||
) : emptyUpdated ? (
|
) : emptyUpdated ? (
|
||||||
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div>
|
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div className="note">This file was deleted in the change.</div></div>
|
||||||
) : emptyOriginal ? (
|
) : emptyOriginal ? (
|
||||||
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
|
<NoOriginal />
|
||||||
) : editable ? (
|
) : editable ? (
|
||||||
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
|
||||||
|
onPick={onPickLine} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
|
||||||
) : (
|
) : (
|
||||||
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
|
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
|
||||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
|
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
|
||||||
)}
|
)}
|
||||||
@@ -546,10 +744,58 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Full-screen side-by-side split view */
|
/** Everything the overlay needs: the original of the open file, the lines the
|
||||||
|
* picked one replaced, and where that picked line sits on screen. */
|
||||||
|
export interface PeekInfo {
|
||||||
|
path: string
|
||||||
|
lines: ViewLine[]
|
||||||
|
lang: string | null
|
||||||
|
wrap: boolean
|
||||||
|
/** Original line to put level with the picked one. */
|
||||||
|
anchor: number
|
||||||
|
/** The picked row's top, measured inside the editor viewport. */
|
||||||
|
top: number
|
||||||
|
empty: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const noop = (): void => undefined
|
||||||
|
|
||||||
|
/* The original file, over the agent column and level with the editor. It takes
|
||||||
|
* no pointer of its own, so a click on it counts as a click outside the code and
|
||||||
|
* closes the panel. */
|
||||||
|
export function OriginalPeek({ path, lines, lang, wrap, anchor, top, empty }: PeekInfo): React.ReactElement {
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
|
// Measured, not counted: a folded line is taller than one row, so only the
|
||||||
|
// real geometry keeps the two versions on one line of the screen.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const scroller = bodyRef.current?.querySelector('.editor') as HTMLElement | null
|
||||||
|
const row = scroller?.querySelector(`.ln-row[data-line="${anchor}"]`) as HTMLElement | null
|
||||||
|
if (!scroller || !row) return
|
||||||
|
scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top - top
|
||||||
|
}, [anchor, top, lines])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="peek">
|
||||||
|
<div className="peek-head">
|
||||||
|
<span className="ph-label">Original</span>
|
||||||
|
<span className="ph-note">before this change · same scroll</span>
|
||||||
|
<span className="ph-hint">click the line again to close</span>
|
||||||
|
</div>
|
||||||
|
<div className="peek-body" ref={bodyRef}>
|
||||||
|
{empty ? <NoOriginal /> : (
|
||||||
|
<PaneView cacheKey={path + ':peek'} path={path} lines={lines} lang={lang} showSign={false} wrap={wrap}
|
||||||
|
cursor={null} selection={null} setCursor={noop} setSelection={noop} onContext={noop} onSymbol={noop} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The Diff view: the two versions side by side, over the whole window. Esc puts
|
||||||
|
* the pane back where it was. */
|
||||||
export function SplitView({ path, side, onClose, onContext }: {
|
export function SplitView({ path, side, onClose, onContext }: {
|
||||||
path: string
|
path: string
|
||||||
/** Which git row opened this file. Split compares that row's pair. */
|
/** Which git row opened this file. Diff compares that row's pair. */
|
||||||
side: DiffSide | null
|
side: DiffSide | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
@@ -608,7 +854,7 @@ export function SplitView({ path, side, onClose, onContext }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="split-pane right">
|
<div className="split-pane right">
|
||||||
<div className="split-label">Updated <span>after</span></div>
|
<div className="split-label">Actual <span>after</span></div>
|
||||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
|
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
|
||||||
{diff.split.map((row, i) => (
|
{diff.split.map((row, i) => (
|
||||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>
|
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>
|
||||||
|
|||||||
@@ -28,8 +28,47 @@ function safeUrl(url: string): string {
|
|||||||
return u // bare relative (e.g. `images/x.png`)
|
return u // bare relative (e.g. `images/x.png`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the URL names a file next to the document instead of a remote one. */
|
||||||
|
function isLocalUrl(u: string): boolean {
|
||||||
|
return !/^(https?:|data:|mailto:|#)/i.test(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a link that a document writes into a project-relative path. The preview
|
||||||
|
* runs from the app's own origin, not from the document's folder, so a bare
|
||||||
|
* `img.png` resolves against the app and finds nothing. `baseDir` is the
|
||||||
|
* folder of the document, and a leading `/` means the project root.
|
||||||
|
*/
|
||||||
|
export function resolveRel(baseDir: string, url: string): string {
|
||||||
|
let u = url.split('#')[0].split('?')[0]
|
||||||
|
try { u = decodeURIComponent(u) } catch { /* a stray % stays literal */ }
|
||||||
|
const segs = (u.startsWith('/') ? u : baseDir + '/' + u).split('/')
|
||||||
|
const out: string[] = []
|
||||||
|
for (const seg of segs) {
|
||||||
|
if (!seg || seg === '.') continue
|
||||||
|
if (seg === '..') { out.pop(); continue }
|
||||||
|
out.push(seg)
|
||||||
|
}
|
||||||
|
return out.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quote one attribute value. The text already passed escapeHtml, so `&`, `<`
|
||||||
|
* and `>` are gone and only the quote itself can still break out. */
|
||||||
|
function attr(value: string): string {
|
||||||
|
return value.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// `(url)`, `(url "title")` or `(<url> 'title')` — the title is not part of the URL.
|
||||||
|
const DEST_RE = /^\s*(?:<([^>]*)>|([^\s)]*))(?:\s+["'(]([^"')]*)["')])?\s*$/
|
||||||
|
/** Split a link destination into its URL and its optional title. */
|
||||||
|
function dest(raw: string): { url: string; title: string } {
|
||||||
|
const m = raw.match(DEST_RE)
|
||||||
|
if (!m) return { url: raw.trim(), title: '' }
|
||||||
|
return { url: (m[1] ?? m[2] ?? '').trim(), title: m[3] ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
/** Inline markdown on one already-untrusted text run. */
|
/** Inline markdown on one already-untrusted text run. */
|
||||||
function inline(src: string): string {
|
function inline(src: string, baseDir: string): string {
|
||||||
// Pull code spans out first so their literal content is never re-processed.
|
// Pull code spans out first so their literal content is never re-processed.
|
||||||
const codes: string[] = []
|
const codes: string[] = []
|
||||||
let s = src.replace(/`([^`]+)`/g, (_m, c) => {
|
let s = src.replace(/`([^`]+)`/g, (_m, c) => {
|
||||||
@@ -37,13 +76,21 @@ function inline(src: string): string {
|
|||||||
return SENT + (codes.length - 1) + SENT
|
return SENT + (codes.length - 1) + SENT
|
||||||
})
|
})
|
||||||
s = HL.escapeHtml(s)
|
s = HL.escapeHtml(s)
|
||||||
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_m, alt, url) => {
|
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, raw) => {
|
||||||
|
const { url, title } = dest(raw)
|
||||||
const u = safeUrl(url)
|
const u = safeUrl(url)
|
||||||
return u ? `<img alt="${alt}" src="${u}" />` : alt
|
if (!u) return alt
|
||||||
|
const t = title ? ` title="${attr(title)}"` : ''
|
||||||
|
// A local image has no src yet: the view reads the file over IPC and fills it.
|
||||||
|
const ref = isLocalUrl(u) ? `data-src="${attr(resolveRel(baseDir, u))}"` : `src="${attr(u)}"`
|
||||||
|
return `<img alt="${attr(alt)}" ${ref}${t} />`
|
||||||
})
|
})
|
||||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => {
|
s = s.replace(/\[([^\]]+)\]\(([^)]*)\)/g, (_m, text, raw) => {
|
||||||
|
const { url, title } = dest(raw)
|
||||||
const u = safeUrl(url)
|
const u = safeUrl(url)
|
||||||
return u ? `<a href="${u}" target="_blank" rel="noreferrer">${t}</a>` : t
|
if (!u) return text
|
||||||
|
const t = title ? ` title="${attr(title)}"` : ''
|
||||||
|
return `<a href="${attr(u)}" target="_blank" rel="noreferrer"${t}>${text}</a>`
|
||||||
})
|
})
|
||||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||||
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>')
|
||||||
@@ -83,17 +130,17 @@ function tableAligns(line: string): (string | null)[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** One `<td>`/`<th>`, with the column's alignment when the header set one. */
|
/** One `<td>`/`<th>`, with the column's alignment when the header set one. */
|
||||||
function cell(tag: string, text: string, align: string | null): string {
|
function cell(tag: string, text: string, align: string | null, baseDir: string): string {
|
||||||
const a = align ? ` style="text-align:${align}"` : ''
|
const a = align ? ` style="text-align:${align}"` : ''
|
||||||
return `<${tag}${a}>` + inline(text) + `</${tag}>`
|
return `<${tag}${a}>` + inline(text, baseDir) + `</${tag}>`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMarkdown(text: string): string {
|
export function renderMarkdown(text: string, baseDir = ''): string {
|
||||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||||
const out: string[] = []
|
const out: string[] = []
|
||||||
let para: string[] = []
|
let para: string[] = []
|
||||||
const flushPara = (): void => {
|
const flushPara = (): void => {
|
||||||
if (para.length) { out.push('<p>' + inline(para.join(' ')) + '</p>'); para = [] }
|
if (para.length) { out.push('<p>' + inline(para.join(' '), baseDir) + '</p>'); para = [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -116,7 +163,7 @@ export function renderMarkdown(text: string): string {
|
|||||||
if (/^\s*$/.test(line)) { flushPara(); i++; continue }
|
if (/^\s*$/.test(line)) { flushPara(); i++; continue }
|
||||||
|
|
||||||
const h = line.match(/^(#{1,6})\s+(.*)$/)
|
const h = line.match(/^(#{1,6})\s+(.*)$/)
|
||||||
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim()) + `</h${n}>`); i++; continue }
|
if (h) { flushPara(); const n = h[1].length; out.push(`<h${n}>` + inline(h[2].trim(), baseDir) + `</h${n}>`); i++; continue }
|
||||||
|
|
||||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
|
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); i++; continue }
|
||||||
|
|
||||||
@@ -132,10 +179,10 @@ export function renderMarkdown(text: string): string {
|
|||||||
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
|
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
|
||||||
rows.push(splitRow(lines[i])); i++
|
rows.push(splitRow(lines[i])); i++
|
||||||
}
|
}
|
||||||
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n])).join('') + '</tr>').join('')
|
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n], baseDir)).join('') + '</tr>').join('')
|
||||||
out.push(
|
out.push(
|
||||||
'<table class="md-table"><thead><tr>' +
|
'<table class="md-table"><thead><tr>' +
|
||||||
head.map((c, n) => cell('th', c, aligns[n])).join('') +
|
head.map((c, n) => cell('th', c, aligns[n], baseDir)).join('') +
|
||||||
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
|
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -146,7 +193,7 @@ export function renderMarkdown(text: string): string {
|
|||||||
flushPara()
|
flushPara()
|
||||||
const buf: string[] = []
|
const buf: string[] = []
|
||||||
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
|
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
|
||||||
out.push('<blockquote>' + renderMarkdown(buf.join('\n')) + '</blockquote>')
|
out.push('<blockquote>' + renderMarkdown(buf.join('\n'), baseDir) + '</blockquote>')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +201,7 @@ export function renderMarkdown(text: string): string {
|
|||||||
flushPara()
|
flushPara()
|
||||||
const items: string[] = []
|
const items: string[] = []
|
||||||
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++ }
|
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*+]\s+/, '')); i++ }
|
||||||
out.push('<ul>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ul>')
|
out.push('<ul>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ul>')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +209,7 @@ export function renderMarkdown(text: string): string {
|
|||||||
flushPara()
|
flushPara()
|
||||||
const items: string[] = []
|
const items: string[] = []
|
||||||
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i++ }
|
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+[.)]\s+/, '')); i++ }
|
||||||
out.push('<ol>' + items.map((it) => '<li>' + inline(it) + '</li>').join('') + '</ol>')
|
out.push('<ol>' + items.map((it) => '<li>' + inline(it, baseDir) + '</li>').join('') + '</ol>')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[]
|
|||||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
||||||
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
|
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
|
||||||
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff · Split' },
|
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff' },
|
||||||
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
|
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -501,10 +501,9 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[]
|
|||||||
group: 'File',
|
group: 'File',
|
||||||
rows: [
|
rows: [
|
||||||
{ keys: ['⌘', 'S'], label: 'Save this file' },
|
{ 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: ['⌘', '.'], label: 'Hidden files on or off' },
|
||||||
{ keys: ['⌘', 'A'], label: 'Auto-fit panels on or off' },
|
{ keys: ['⌘', 'A'], label: 'Select all in this pane' },
|
||||||
|
{ keys: ['⌘', 'L'], label: 'Fluid columns on or off' },
|
||||||
{ keys: ['Esc'], label: 'Close an overlay or a split view' },
|
{ keys: ['Esc'], label: 'Close an overlay or a split view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface ProjectData {
|
|||||||
changes: Change[]
|
changes: Change[]
|
||||||
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
|
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
|
||||||
diffs: Record<string, Diff>
|
diffs: Record<string, Diff>
|
||||||
/** Per row id: that row's own pair. Drives Diff and Split. */
|
/** Per row id: that row's own pair. Drives Diff. */
|
||||||
rowDiffs: Record<string, Diff>
|
rowDiffs: Record<string, Diff>
|
||||||
/** Paths that have a staged row. */
|
/** Paths that have a staged row. */
|
||||||
staged: Set<string>
|
staged: Set<string>
|
||||||
|
|||||||
@@ -169,7 +169,9 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); }
|
.tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); }
|
||||||
.tb-toggle.muted:hover { color:var(--fg-2); }
|
.tb-toggle.muted:hover { color:var(--fg-2); }
|
||||||
|
|
||||||
.workbench { flex:1; display:flex; min-height:0; }
|
/* position:relative so the Diff hover overlay can pin itself to the body row —
|
||||||
|
top, right and bottom — and cover the agent column and nothing else. */
|
||||||
|
.workbench { flex:1; display:flex; min-height:0; overflow:hidden; position:relative; }
|
||||||
|
|
||||||
/* --col-bg holds each column's resting background so the ⌘R flash below can
|
/* --col-bg holds each column's resting background so the ⌘R flash below can
|
||||||
animate back to it, whichever state the column is in. */
|
animate back to it, whichever state the column is in. */
|
||||||
@@ -357,14 +359,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 0; min-width:0; }
|
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 0; min-width:0; }
|
||||||
.editor.diff .ln-code { padding-left:6px; }
|
.editor.diff .ln-code { padding-left:6px; }
|
||||||
/* Wrapped read-only view: the line folds inside its own column, so the number
|
/* Wrapped read-only view: the line folds inside its own column, so the number
|
||||||
column keeps its single number at the top of the block. The grid track must
|
column keeps its single number at the top of the block. Block flow, not grid:
|
||||||
drop max-content here — a folded line still measures its full unfolded width,
|
an auto grid row takes its height from the row's *unwrapped* line box, so a
|
||||||
which would widen the row instead of breaking it. */
|
folded line kept a 20px row and painted over the lines under it. Nothing is
|
||||||
.editor.wrap { overflow-x:hidden; grid-template-columns:1fr; }
|
lost — with no sideways scroll the max-content track has no work to do. */
|
||||||
|
.editor.wrap { display:block; overflow-x:hidden; }
|
||||||
.editor.wrap .ln-code { flex:1 1 auto; white-space:pre-wrap; overflow-wrap:break-word; }
|
.editor.wrap .ln-code { flex:1 1 auto; white-space:pre-wrap; overflow-wrap:break-word; }
|
||||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:18px; background:var(--bg-0); }
|
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:18px; background:var(--bg-0); }
|
||||||
.empty-ed svg { display:none; }
|
.empty-ed svg { display:none; }
|
||||||
.empty-ed .big { font-family:var(--ui); font-size:14px; line-height:1.7; color:var(--fg-3); }
|
.empty-ed .big { font-family:var(--ui); font-size:14px; line-height:1.7; color:var(--fg-3); }
|
||||||
|
.empty-ed .note { font-family:var(--mono); font-size:12px; color:var(--fg-3); }
|
||||||
.empty-ed .klist { display:flex; flex-direction:column; gap:6px; font-size:13px; }
|
.empty-ed .klist { display:flex; flex-direction:column; gap:6px; font-size:13px; }
|
||||||
.empty-ed .klist div { display:flex; gap:14px; align-items:center; justify-content:space-between; min-width:260px; padding:6px 0; color:var(--fg-1); }
|
.empty-ed .klist div { display:flex; gap:14px; align-items:center; justify-content:space-between; min-width:260px; padding:6px 0; color:var(--fg-1); }
|
||||||
|
|
||||||
@@ -374,11 +378,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
|
letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
|
||||||
.diff-bar .a{color:var(--add);font-family:var(--mono);font-weight:500;font-size:12px;}
|
.diff-bar .a{color:var(--add);font-family:var(--mono);font-weight:500;font-size:12px;}
|
||||||
.diff-bar .d{color:var(--del);font-family:var(--mono);font-weight:500;font-size:12px;}
|
.diff-bar .d{color:var(--del);font-family:var(--mono);font-weight:500;font-size:12px;}
|
||||||
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
|
/* segmented view control — the active side is the amber fill with ink text */
|
||||||
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
|
|
||||||
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
|
|
||||||
|
|
||||||
/* four-segment view control — the active side is the amber fill with ink text */
|
|
||||||
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:var(--r-sm); overflow:hidden; }
|
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:var(--r-sm); overflow:hidden; }
|
||||||
.diff-bar .seg button { background:transparent; border:0; border-left:1px solid var(--border-2); color:var(--fg-2);
|
.diff-bar .seg button { background:transparent; border:0; border-left:1px solid var(--border-2); color:var(--fg-2);
|
||||||
font-family:var(--ui); font-size:11px; font-weight:400; line-height:1; padding:5px 12px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
font-family:var(--ui); font-size:11px; font-weight:400; line-height:1; padding:5px 12px; cursor:pointer; display:flex; align-items:center; gap:6px;
|
||||||
@@ -386,7 +386,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.diff-bar .seg button:first-child { border-left:0; }
|
.diff-bar .seg button:first-child { border-left:0; }
|
||||||
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
||||||
.diff-bar .seg button.on, .diff-bar .seg button.on:hover { background:var(--accent); color:#171C22; font-weight:500; }
|
.diff-bar .seg button.on, .diff-bar .seg button.on:hover { background:var(--accent); color:#171C22; font-weight:500; }
|
||||||
.diff-bar .seg .split-btn svg { display:none; }
|
|
||||||
.diff-bar .db-lang { font-family:var(--mono); font-weight:700; font-size:10px; color:var(--fg-3); letter-spacing:.14em; text-transform:uppercase; }
|
.diff-bar .db-lang { font-family:var(--mono); font-weight:700; font-size:10px; color:var(--fg-3); letter-spacing:.14em; text-transform:uppercase; }
|
||||||
/* Which pair the diff compares. Only shown when a file is staged AND edited
|
/* Which pair the diff compares. Only shown when a file is staged AND edited
|
||||||
again, so the two git rows can be told apart. */
|
again, so the two git rows can be told apart. */
|
||||||
@@ -433,6 +432,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
color:var(--accent-lite); font-style:italic; font-size:15px; }
|
color:var(--accent-lite); font-style:italic; font-size:15px; }
|
||||||
.md-body hr { border:0; border-top:1px solid var(--border); margin:1.6em 0; }
|
.md-body hr { border:0; border-top:1px solid var(--border); margin:1.6em 0; }
|
||||||
.md-body img { max-width:100%; border-radius:var(--r-sm); }
|
.md-body img { max-width:100%; border-radius:var(--r-sm); }
|
||||||
|
/* A local image whose file could not be read keeps only its alt text. */
|
||||||
|
.md-body img:not([src]) { color:var(--fg-3); font-family:var(--mono); font-size:12px; }
|
||||||
.md-body code { font-family:var(--code-font); font-size:13px; color:var(--accent-lite); background:var(--bg-2);
|
.md-body code { font-family:var(--code-font); font-size:13px; color:var(--accent-lite); background:var(--bg-2);
|
||||||
border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; }
|
border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 5px; }
|
||||||
.md-body pre.md-code { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-sm); padding:14px 16px; overflow:auto; margin:1.2em 0; }
|
.md-body pre.md-code { background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-sm); padding:14px 16px; overflow:auto; margin:1.2em 0; }
|
||||||
@@ -446,14 +447,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
font-size:10px; letter-spacing:.14em; text-transform:uppercase; white-space:nowrap; }
|
font-size:10px; letter-spacing:.14em; text-transform:uppercase; white-space:nowrap; }
|
||||||
.md-body table.md-table tbody tr:last-child td { border-bottom:0; }
|
.md-body table.md-table tbody tr:last-child td { border-bottom:0; }
|
||||||
|
|
||||||
/* gutter change bars (Original / Updated / Split) */
|
/* gutter change bars (Original / Diff) */
|
||||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||||
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
|
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
|
||||||
.ln-row.empty { background:transparent; }
|
.ln-row.empty { background:transparent; }
|
||||||
|
|
||||||
/* full-screen split */
|
/* Diff — the two versions side by side, over the whole window */
|
||||||
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; }
|
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; }
|
||||||
/* The split covers the whole window, title bar included, so its own header has
|
/* The view covers the whole window, title bar included, so its own header has
|
||||||
to reserve the space macOS keeps for the traffic lights — otherwise the file
|
to reserve the space macOS keeps for the traffic lights — otherwise the file
|
||||||
badge and the path sit underneath them. */
|
badge and the path sit underneath them. */
|
||||||
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:14px; padding:0 16px 0 82px;
|
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:14px; padding:0 16px 0 82px;
|
||||||
@@ -478,6 +479,42 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.split-body .ln-row { min-height:22px; }
|
.split-body .ln-row { min-height:22px; }
|
||||||
.split-body .ln-gutter { flex:0 0 52px; width:52px; }
|
.split-body .ln-gutter { flex:0 0 52px; width:52px; }
|
||||||
|
|
||||||
|
/* ============ in-pane diff (Actual) ============ */
|
||||||
|
/* A changed line in the editable buffer. The tint has to sit under the
|
||||||
|
transparent textarea, so it is a band placed on the line's own geometry —
|
||||||
|
never a row background, which would paint over the caret layer. */
|
||||||
|
.ce-band { position:absolute; left:0; right:0; min-width:100%; z-index:0; pointer-events:none;
|
||||||
|
background:var(--add-bg); box-shadow:inset 2px 0 0 var(--add); }
|
||||||
|
/* A run of removed lines owns no line here, so its rule goes on the edge of the
|
||||||
|
line that took its place. */
|
||||||
|
.ce-gaprule { position:absolute; left:0; right:0; min-width:100%; height:2px; z-index:0; pointer-events:none; background:var(--del); }
|
||||||
|
/* The number of a changed line, in both layouts: a span in the scrolled gutter
|
||||||
|
column, the counter on the line block once the buffer wraps. */
|
||||||
|
.ce-gutter .chg { color:var(--add); }
|
||||||
|
.ce-line.chg::before { color:var(--add); }
|
||||||
|
|
||||||
|
/* The hover reveal: the whole original file over the agent column, level with
|
||||||
|
the editor. It takes no pointer, so a click on it counts as a click outside
|
||||||
|
the code and closes the panel. 496px is the design width of that column plus its 2px rule. */
|
||||||
|
.peek {
|
||||||
|
position:absolute; top:0; right:0; bottom:0; width:496px; z-index:40;
|
||||||
|
display:flex; flex-direction:column; overflow:hidden; pointer-events:none;
|
||||||
|
background:var(--bg-0); border-left:2px solid var(--del); box-shadow:var(--shadow-menu);
|
||||||
|
}
|
||||||
|
/* The same height as the view strip, so line one of both panes shares a y. */
|
||||||
|
.peek-head {
|
||||||
|
height:var(--bar-h); flex:0 0 var(--bar-h); display:flex; align-items:center; gap:12px;
|
||||||
|
padding:0 14px; background:var(--bg-2); border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.peek-head .ph-label { font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
|
||||||
|
letter-spacing:.14em; text-transform:uppercase; color:var(--del); }
|
||||||
|
.peek-head .ph-note, .peek-head .ph-hint { font-family:var(--mono); font-size:11px; line-height:1; color:var(--fg-3); }
|
||||||
|
.peek-head .ph-hint { margin-left:auto; }
|
||||||
|
.peek-body { flex:1; min-height:0; display:flex; flex-direction:column; }
|
||||||
|
/* Amber is the previous state, so the line the hovered one replaced reads one
|
||||||
|
step below the current text. */
|
||||||
|
.peek-body .ln-row.del .ln-code { color:var(--fg-1); }
|
||||||
|
|
||||||
/* syntax token colors — applied to both the read-only line views (.ln-code)
|
/* syntax token colors — applied to both the read-only line views (.ln-code)
|
||||||
* and the editable buffer's highlight layer (.ce-pre) */
|
* and the editable buffer's highlight layer (.ce-pre) */
|
||||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
|
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
|
||||||
@@ -500,7 +537,10 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
|
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
|
||||||
|
|
||||||
/* ============ terminals (right column) ============ */
|
/* ============ terminals (right column) ============ */
|
||||||
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
|
/* min-width:0 is load-bearing: the pane is a row-flex item, so min-width:auto
|
||||||
|
would be the xterm's own rendered width. The pane could then grow with the
|
||||||
|
column but never shrink, and the terminal would keep the wider cols. */
|
||||||
|
.term-pane { display:flex; flex-direction:column; min-width:0; min-height:0; background:var(--bg-1); }
|
||||||
/* 30px panel header, same label as every other column. A live pane marks itself
|
/* 30px panel header, same label as every other column. A live pane marks itself
|
||||||
with an amber dot; nothing pulses. */
|
with an amber dot; nothing pulses. */
|
||||||
.term-head { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px; padding:0 12px; background:var(--bg-2); border-bottom:1px solid var(--border);
|
.term-head { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px; padding:0 12px; background:var(--bg-2); border-bottom:1px solid var(--border);
|
||||||
@@ -788,9 +828,12 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
/* xterm.js host (real terminals) */
|
/* xterm.js host (real terminals) */
|
||||||
/* The pane carries the colour, taken from the terminal palette in config.json,
|
/* The pane carries the colour, taken from the terminal palette in config.json,
|
||||||
so the padding around the canvas can never sit on a different ground. */
|
so the padding around the canvas can never sit on a different ground. */
|
||||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:10px 6px 10px 12px; background:var(--bg-0); }
|
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:12px; background:var(--bg-0); }
|
||||||
.term-xterm .xterm { height:100%; }
|
.term-xterm .xterm { height:100%; }
|
||||||
.term-xterm .xterm-viewport { background:transparent !important; }
|
/* The scrollbar takes layout width inside the viewport, so at right:0 it would
|
||||||
|
stand on the last column of text. Pushed into the right padding it overlays
|
||||||
|
empty ground, and the grid keeps all four gaps equal (see fitTerm). */
|
||||||
|
.term-xterm .xterm-viewport { background:transparent !important; right:-9px; }
|
||||||
.term-xterm .xterm-viewport::-webkit-scrollbar { width:9px; }
|
.term-xterm .xterm-viewport::-webkit-scrollbar { width:9px; }
|
||||||
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.13); border-radius:5px; border:2px solid transparent; background-clip:content-box; }
|
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.13); border-radius:5px; border:2px solid transparent; background-clip:content-box; }
|
||||||
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); background-clip:content-box; }
|
.term-xterm .xterm-viewport::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); background-clip:content-box; }
|
||||||
@@ -841,10 +884,10 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.lp-foot b { color:var(--accent); font-weight:400; }
|
.lp-foot b { color:var(--accent); font-weight:400; }
|
||||||
|
|
||||||
/* ============ keyboard-shortcuts (help) modal ============ */
|
/* ============ keyboard-shortcuts (help) modal ============ */
|
||||||
/* Project note (.notes.txt). Capped at 1000px so the text stays readable on a
|
/* Project note (.notes.txt). The modal is as wide as the text column, so no
|
||||||
wide screen; the height fills nearly the whole window, with a floor for small
|
empty band stays next to the scrollbar; the height fills nearly the whole
|
||||||
ones, because a note is usually long. */
|
window, with a floor for small ones, because a note is usually long. */
|
||||||
.notes-modal { width:1000px; max-width:100%; height:100%;
|
.notes-modal { width:780px; max-width:100%; height:100%;
|
||||||
background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-lg);
|
background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-lg);
|
||||||
box-shadow:var(--shadow-overlay); overflow:hidden; display:flex; flex-direction:column; }
|
box-shadow:var(--shadow-overlay); overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.notes-modal .pi { height:50px; flex:0 0 50px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--border); }
|
.notes-modal .pi { height:50px; flex:0 0 50px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--border); }
|
||||||
@@ -858,7 +901,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
|
|||||||
.notes-modal .notes-hint kbd { color:#171C22; border-color:rgba(23,28,34,.25); font-size:11px; padding:0; }
|
.notes-modal .notes-hint kbd { color:#171C22; border-color:rgba(23,28,34,.25); font-size:11px; padding:0; }
|
||||||
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
|
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
|
||||||
padding:18px 20px; color:var(--fg-1); font-family:var(--code-font); font-size:14px; line-height:1.7;
|
padding:18px 20px; color:var(--fg-1); font-family:var(--code-font); font-size:14px; line-height:1.7;
|
||||||
max-width:calc(68ch + 40px); caret-color:var(--accent); }
|
caret-color:var(--accent); }
|
||||||
.notes-input::placeholder { color:var(--fg-3); }
|
.notes-input::placeholder { color:var(--fg-3); }
|
||||||
.notes-foot { height:34px; flex:0 0 34px; display:flex; align-items:center; padding:0 20px;
|
.notes-foot { height:34px; flex:0 0 34px; display:flex; align-items:center; padding:0 20px;
|
||||||
border-top:1px solid var(--border); font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
border-top:1px solid var(--border); font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
* references can be stacked before the user hits Enter. */
|
* references can be stacked before the user hits Enter. */
|
||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Terminal as XTerm } from '@xterm/xterm'
|
import { Terminal as XTerm } from '@xterm/xterm'
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
|
||||||
import '@xterm/xterm/css/xterm.css'
|
import '@xterm/xterm/css/xterm.css'
|
||||||
import { ContextMenu } from './overlays'
|
import { ContextMenu } from './overlays'
|
||||||
import type { Menu } from './overlays'
|
import type { Menu } from './overlays'
|
||||||
@@ -45,10 +44,41 @@ function xtermOptions(cfg: HelderConfig['terminal']): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gap between the grid and the pane edge, in px. */
|
||||||
|
const PAD = 12
|
||||||
|
type Cell = { width: number; height: number }
|
||||||
|
|
||||||
|
function cellSize(term: XTerm): Cell | null {
|
||||||
|
const core = (term as unknown as {
|
||||||
|
_core?: { _renderService?: { dimensions?: { css?: { cell?: Cell } } } }
|
||||||
|
})._core
|
||||||
|
const cell = core?._renderService?.dimensions?.css?.cell
|
||||||
|
return cell && cell.width > 0 && cell.height > 0 ? cell : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes the grid to the host and centres it. FitAddon is not used here: it
|
||||||
|
* reserves 14px on the right for an overview ruler we never draw, and it leaves
|
||||||
|
* the sub-cell remainder on one side, so the right and bottom gaps came out far
|
||||||
|
* wider than the left and top. Both measurements come from clientWidth, which
|
||||||
|
* counts the padding, so writing the padding back cannot change the result and
|
||||||
|
* the ResizeObserver settles after one pass. */
|
||||||
|
function fitTerm(term: XTerm, host: HTMLElement): void {
|
||||||
|
const cell = cellSize(term)
|
||||||
|
if (!cell) return
|
||||||
|
const innerW = host.clientWidth - PAD * 2
|
||||||
|
const innerH = host.clientHeight - PAD * 2
|
||||||
|
const cols = Math.max(2, Math.floor(innerW / cell.width))
|
||||||
|
const rows = Math.max(1, Math.floor(innerH / cell.height))
|
||||||
|
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows)
|
||||||
|
const x = PAD + Math.max(0, innerW - cols * cell.width) / 2
|
||||||
|
const y = PAD + Math.max(0, innerH - rows * cell.height) / 2
|
||||||
|
host.style.padding = `${y}px ${x}px`
|
||||||
|
}
|
||||||
|
|
||||||
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
||||||
const hostRef = useRef<HTMLDivElement>(null)
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
const termRef = useRef<XTerm | null>(null)
|
const termRef = useRef<XTerm | null>(null)
|
||||||
const fitRef = useRef<FitAddon | null>(null)
|
const fitRef = useRef<(() => void) | null>(null)
|
||||||
// The terminal is built once and lives as long as its PTY, so the config is
|
// The terminal is built once and lives as long as its PTY, so the config is
|
||||||
// read through a ref on creation and re-applied by the effect below — editing
|
// read through a ref on creation and re-applied by the effect below — editing
|
||||||
// config.json restyles the running session instead of restarting it.
|
// config.json restyles the running session instead of restarting it.
|
||||||
@@ -69,10 +99,9 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
|
|
||||||
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
|
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
|
||||||
termRef.current = term
|
termRef.current = term
|
||||||
const fit = new FitAddon()
|
|
||||||
fitRef.current = fit
|
|
||||||
term.loadAddon(fit)
|
|
||||||
term.open(host)
|
term.open(host)
|
||||||
|
const refit = (): void => fitTerm(term, host)
|
||||||
|
fitRef.current = refit
|
||||||
|
|
||||||
// Both panes (D1 agent + D2 shell): selecting text auto-copies it to the clipboard.
|
// Both panes (D1 agent + D2 shell): selecting text auto-copies it to the clipboard.
|
||||||
if (bridge) {
|
if (bridge) {
|
||||||
@@ -81,7 +110,16 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
if (sel) bridge.clipboard.writeText(sel)
|
if (sel) bridge.clipboard.writeText(sel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
try { fit.fit() } catch { /* host not measured yet */ }
|
// ⌘A marks the whole buffer. xterm types on a hidden textarea, so the
|
||||||
|
// browser default would select that instead of the terminal's own text.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (e.type !== 'keydown' || !e.metaKey || e.ctrlKey || e.altKey) return true
|
||||||
|
if (e.key.toLowerCase() !== 'a') return true
|
||||||
|
e.preventDefault()
|
||||||
|
term.selectAll()
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
try { refit() } catch { /* host not measured yet */ }
|
||||||
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let id = -1
|
let id = -1
|
||||||
@@ -119,6 +157,11 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
bridge.pty.write(id, d)
|
bridge.pty.write(id, d)
|
||||||
})
|
})
|
||||||
term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
|
term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
|
||||||
|
// create() carried the size measured before this promise resolved. A fit
|
||||||
|
// in between changed xterm only, and onResize fires on a change, never on
|
||||||
|
// the current value, so the PTY would keep the stale width and the agent
|
||||||
|
// would wrap at more columns than the pane shows.
|
||||||
|
bridge.pty.resize(id, term.cols, term.rows)
|
||||||
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
|
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -136,7 +179,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
if (frame) return
|
if (frame) return
|
||||||
frame = requestAnimationFrame(() => {
|
frame = requestAnimationFrame(() => {
|
||||||
frame = 0
|
frame = 0
|
||||||
try { fit.fit() } catch { /* host not measured yet */ }
|
try { refit() } catch { /* host not measured yet */ }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
ro.observe(host)
|
ro.observe(host)
|
||||||
@@ -162,7 +205,7 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
|||||||
const term = termRef.current
|
const term = termRef.current
|
||||||
if (!term) return
|
if (!term) return
|
||||||
Object.assign(term.options, xtermOptions(cfg))
|
Object.assign(term.options, xtermOptions(cfg))
|
||||||
fitRef.current?.fit()
|
fitRef.current?.()
|
||||||
}, [cfg])
|
}, [cfg])
|
||||||
|
|
||||||
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
||||||
|
|||||||
@@ -78,8 +78,12 @@ export interface ViewLine {
|
|||||||
text: string
|
text: string
|
||||||
sign?: string
|
sign?: string
|
||||||
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
|
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
|
||||||
|
/** A run of removed lines has no line of its own on the current side, so its
|
||||||
|
* rule sits on an edge of the line that took its place. */
|
||||||
|
gap?: 'above' | 'below' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** View a changed file opens in. 'diff' is the full-screen side-by-side overlay. */
|
||||||
export type DiffMode = 'original' | 'updated' | 'diff'
|
export type DiffMode = 'original' | 'updated' | 'diff'
|
||||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||||
@@ -126,7 +130,7 @@ export interface HelderConfig {
|
|||||||
export const DEFAULT_CONFIG: HelderConfig = {
|
export const DEFAULT_CONFIG: HelderConfig = {
|
||||||
ai: { command: 'claude', autoLaunch: true },
|
ai: { command: 'claude', autoLaunch: true },
|
||||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
|
||||||
files: { exclude: [], followGitignore: true },
|
files: { exclude: [], followGitignore: true },
|
||||||
terminal: {
|
terminal: {
|
||||||
shell: null,
|
shell: null,
|
||||||
|
|||||||
71
test/deleted-lines.test.tsx
Normal file
71
test/deleted-lines.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A removed line owns no line in Actual, so it cannot carry a band. It leaves a
|
||||||
|
// rule on the edge of the line that took its place instead. The mock project has
|
||||||
|
// no such change, so this drives the renderer with a git payload of its own.
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
import { installBridge, removeBridge } from './stub-bridge'
|
||||||
|
|
||||||
|
const HEAD = 'keep\ngone\ntail\n'
|
||||||
|
const DISK = 'keep\ntail\n'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(() => {
|
||||||
|
installBridge([{ path: 'demo.txt', status: 'M', staged: false, original: HEAD, updated: DISK }], { 'demo.txt': DISK })
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
removeBridge()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function openRow(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
const row = await waitFor(() => {
|
||||||
|
const r = c.querySelector<HTMLElement>('.git-row')
|
||||||
|
if (!r) throw new Error('git not ready')
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
fireEvent.click(row)
|
||||||
|
await waitFor(() => expect(c.querySelector('.code-edit')).toBeTruthy())
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a change that only removes a line', () => {
|
||||||
|
it('rules the edge of the line below it, and tints nothing', async () => {
|
||||||
|
const c = await openRow()
|
||||||
|
await waitFor(() => expect(c.querySelectorAll('.ce-gaprule')).toHaveLength(1))
|
||||||
|
expect(c.querySelector('.ce-band')).toBeNull()
|
||||||
|
expect(c.querySelector('.ce-gutter .chg')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hands the removed line to the picked panel', async () => {
|
||||||
|
const c = await openRow()
|
||||||
|
const scroll = await waitFor(() => {
|
||||||
|
const el = c.querySelector<HTMLElement>('.ce-scroll')
|
||||||
|
if (!el || !c.querySelector('.ce-gaprule')) throw new Error('rule not ready')
|
||||||
|
return el
|
||||||
|
})
|
||||||
|
// 'tail' took the place of the removed line, and sits on line 2.
|
||||||
|
fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
|
||||||
|
const peek = await waitFor(() => {
|
||||||
|
const p = c.querySelector<HTMLElement>('.peek')
|
||||||
|
if (!p) throw new Error('peek not ready')
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
expect(Array.from(peek.querySelectorAll('.ln-row.del')).map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['gone'])
|
||||||
|
})
|
||||||
|
})
|
||||||
45
test/diff-view.test.ts
Normal file
45
test/diff-view.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildDiffView } from '../src/renderer/src/editor'
|
||||||
|
import { makeDiff } from '../src/renderer/src/diff'
|
||||||
|
|
||||||
|
/** The current-side view of one text pair, plus the map the overlay reads. */
|
||||||
|
function view(original: string, updated: string): ReturnType<typeof buildDiffView> {
|
||||||
|
return buildDiffView(makeDiff('M', original, updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildDiffView', () => {
|
||||||
|
it('maps an added line to the line it replaced', () => {
|
||||||
|
const { lines, peek } = view('a\nold\nb', 'a\nnew\nb')
|
||||||
|
expect(lines.map((l) => l.text)).toEqual(['a', 'new', 'b'])
|
||||||
|
expect(lines[1].row).toBe('add')
|
||||||
|
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
|
||||||
|
expect(lines.every((l) => !l.gap)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a pure deletion on the line that follows it', () => {
|
||||||
|
const { lines, peek } = view('a\ngone\nb', 'a\nb')
|
||||||
|
expect(lines[1].text).toBe('b')
|
||||||
|
expect(lines[1].gap).toBe('above')
|
||||||
|
expect(lines[1].row).toBeNull()
|
||||||
|
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a deletion at end of file under the last line', () => {
|
||||||
|
const { lines, peek } = view('a\nb\ntail', 'a\nb')
|
||||||
|
expect(lines[1].gap).toBe('below')
|
||||||
|
expect(peek.get(2)).toEqual({ anchor: 3, from: 3, to: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('anchors an insertion but marks nothing as removed', () => {
|
||||||
|
const { lines, peek } = view('a\nb', 'a\nNEW\nb')
|
||||||
|
expect(lines[1].row).toBe('add')
|
||||||
|
expect(peek.get(2)).toEqual({ anchor: 1, from: null, to: null })
|
||||||
|
expect(lines.every((l) => !l.gap)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves an unchanged file without marks or peek targets', () => {
|
||||||
|
const { lines, peek } = view('a\nb\n', 'a\nb\n')
|
||||||
|
expect(peek.size).toBe(0)
|
||||||
|
expect(lines.every((l) => !l.row && !l.gap)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -69,3 +69,33 @@ describe('makeDiff', () => {
|
|||||||
expect(deleted.added).toBe(false)
|
expect(deleted.added).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('buildDiff on large files', () => {
|
||||||
|
it('diffs a 60k-line file without exhausting the heap', () => {
|
||||||
|
const base = Array.from({ length: 60_000 }, (_, i) => `line ${i}`)
|
||||||
|
const changed = base.slice()
|
||||||
|
changed[30_000] = 'line 30000 edited'
|
||||||
|
const d = buildDiff(base.join('\n'), changed.join('\n'))
|
||||||
|
expect(d.add).toBe(1)
|
||||||
|
expect(d.del).toBe(1)
|
||||||
|
expect(d.right[30_000].mark).toBe('add')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a full rewrite of a large file with no shared lines', () => {
|
||||||
|
const a = Array.from({ length: 40_000 }, (_, i) => `old ${i}`).join('\n')
|
||||||
|
const b = Array.from({ length: 40_000 }, (_, i) => `new ${i}`).join('\n')
|
||||||
|
const d = buildDiff(a, b)
|
||||||
|
expect(d.del).toBe(40_000)
|
||||||
|
expect(d.add).toBe(40_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the unchanged head and tail of a large, heavily edited file', () => {
|
||||||
|
const head = Array.from({ length: 5_000 }, (_, i) => `head ${i}`)
|
||||||
|
const tail = Array.from({ length: 5_000 }, (_, i) => `tail ${i}`)
|
||||||
|
const mid = (p: string): string[] => Array.from({ length: 30_000 }, (_, i) => `${p} ${i % 7}`)
|
||||||
|
const d = buildDiff([...head, ...mid('x'), ...tail].join('\n'), [...head, ...mid('y'), ...tail].join('\n'))
|
||||||
|
expect(d.left.slice(0, 5_000).every((l) => l.mark === null)).toBe(true)
|
||||||
|
expect(d.left.slice(-5_000).every((l) => l.mark === null)).toBe(true)
|
||||||
|
expect(d.add).toBe(30_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -44,15 +44,73 @@ describe('Editor view modes', () => {
|
|||||||
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
|
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
|
it('a git row opens the file in Actual, not in the full-screen Diff', async () => {
|
||||||
const c = await openChanged()
|
const c = await openChanged()
|
||||||
fireEvent.click(c.querySelector('.split-btn')!)
|
expect(c.querySelector('.split-overlay')).toBeNull()
|
||||||
|
expect(find(c, '.seg button.on', 'Actual')).toBeTruthy()
|
||||||
|
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Actual bands every changed line, over a buffer that stays editable', async () => {
|
||||||
|
const c = await openChanged()
|
||||||
|
// The mock rewrites six lines of UserController.php and removes none.
|
||||||
|
await waitFor(() => expect(c.querySelectorAll('.ce-band')).toHaveLength(6))
|
||||||
|
expect(c.querySelector('.ce-gaprule')).toBeNull()
|
||||||
|
expect(c.querySelectorAll('.ce-gutter .chg')).toHaveLength(6)
|
||||||
|
expect(c.querySelector('.ln-sign')).toBeNull()
|
||||||
|
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).readOnly).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the Diff segment opens the full-screen view, and Esc puts back the mode it covered', async () => {
|
||||||
|
const c = await openChanged()
|
||||||
|
fireEvent.click(find(c, '.seg button', 'Original')!)
|
||||||
|
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
|
||||||
|
|
||||||
|
fireEvent.click(find(c, '.seg button', 'Diff')!)
|
||||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
|
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
|
||||||
expect(c.querySelector('.split-pane.left')).toBeTruthy()
|
|
||||||
expect(c.querySelector('.split-pane.right')).toBeTruthy()
|
|
||||||
|
|
||||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
|
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
|
||||||
|
expect(find(c, '.seg button.on', 'Original')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a band in Actual reveals the original, clicking it again hides it', async () => {
|
||||||
|
const c = await openChanged()
|
||||||
|
const scroll = await waitFor(() => {
|
||||||
|
const el = c.querySelector<HTMLElement>('.ce-scroll')
|
||||||
|
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
|
||||||
|
return el
|
||||||
|
})
|
||||||
|
// Line 30 is the first rewritten line. Unwrapped, its band is arithmetic:
|
||||||
|
// a 10px top pad plus 20px per line, and the hit test reads clientY.
|
||||||
|
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 })
|
||||||
|
const peek = await waitFor(() => {
|
||||||
|
const p = c.querySelector<HTMLElement>('.peek')
|
||||||
|
if (!p) throw new Error('peek not ready')
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
|
||||||
|
expect(removed).toHaveLength(1)
|
||||||
|
expect(removed[0].textContent).toContain("'plan' => $user->plan,")
|
||||||
|
|
||||||
|
// A second click on the same band drops it.
|
||||||
|
fireEvent.click(scroll, { clientY: 10 + 29 * 20 + 5 })
|
||||||
|
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⌘M cycles Actual → Original → Diff, and nothing else', async () => {
|
||||||
|
const c = await openChanged()
|
||||||
|
const seen: string[] = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', metaKey: true })) })
|
||||||
|
await waitFor(() => {
|
||||||
|
const on = c.querySelector('.seg button.on')?.textContent ?? ''
|
||||||
|
if (seen[seen.length - 1] === on) throw new Error('mode not changed')
|
||||||
|
seen.push(on)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A tab opens in Actual, so the first ⌘M already leaves it.
|
||||||
|
expect(seen).toEqual(['Original', 'Diff', 'Actual'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -14,59 +14,21 @@ vi.mock('../src/renderer/src/terminals', () => {
|
|||||||
|
|
||||||
import { App } from '../src/renderer/src/App'
|
import { App } from '../src/renderer/src/App'
|
||||||
import { ProjectProvider } from '../src/renderer/src/project'
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
import { installBridge, removeBridge } from './stub-bridge'
|
||||||
|
|
||||||
const HEAD = 'a\nb\nc\n'
|
const HEAD = 'a\nb\nc\n'
|
||||||
const INDEX = 'a\nSTAGED\nc\n'
|
const INDEX = 'a\nSTAGED\nc\n'
|
||||||
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
|
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
|
||||||
|
|
||||||
/** Minimal preload bridge: enough for the store to boot with real git rows. */
|
/** Exactly what git-service returns for porcelain "MM". */
|
||||||
function stubBridge(): void {
|
function stubBridge(): void {
|
||||||
const noop = (): void => {}
|
installBridge(
|
||||||
const off = (): (() => void) => noop
|
[
|
||||||
;(window as unknown as { helder: unknown }).helder = {
|
|
||||||
platform: 'darwin',
|
|
||||||
clipboard: { writeText: noop, readText: () => '' },
|
|
||||||
project: {
|
|
||||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
|
||||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
|
||||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
|
||||||
recent: async () => [],
|
|
||||||
},
|
|
||||||
fs: {
|
|
||||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
|
|
||||||
readDir: async () => [],
|
|
||||||
files: async () => ({ 'demo.txt': DISK }),
|
|
||||||
read: async () => DISK,
|
|
||||||
imageDataUrl: async () => '',
|
|
||||||
write: async () => {},
|
|
||||||
delete: async () => {},
|
|
||||||
create: async () => {},
|
|
||||||
mkdir: async () => {},
|
|
||||||
},
|
|
||||||
shell: { reveal: noop },
|
|
||||||
notes: { read: async () => '', write: async () => {} },
|
|
||||||
git: {
|
|
||||||
// Exactly what git-service now returns for porcelain "MM".
|
|
||||||
load: async () => ({
|
|
||||||
branch: 'main',
|
|
||||||
changes: [
|
|
||||||
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
|
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
|
||||||
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
|
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
|
||||||
],
|
],
|
||||||
}),
|
{ 'demo.txt': DISK },
|
||||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
)
|
||||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
|
||||||
},
|
|
||||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
|
||||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
|
||||||
recent: { get: async () => [], set: async () => {} },
|
|
||||||
search: { content: async () => [], files: async () => [] },
|
|
||||||
dialog: { unsavedClose: async () => 'cancel' },
|
|
||||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
|
||||||
onProjectChanged: off,
|
|
||||||
onConfigChanged: off,
|
|
||||||
onRefresh: off,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -77,12 +39,17 @@ beforeEach(stubBridge)
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
delete (window as unknown as { helder?: unknown }).helder
|
removeBridge()
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Row text of the view currently on screen. */
|
/** Row text of the read-only view currently on screen. */
|
||||||
function viewText(c: HTMLElement): string {
|
function viewText(c: HTMLElement): string {
|
||||||
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
|
return Array.from(c.querySelectorAll('.editor-wrap .ln-row')).map((el) => el.textContent ?? '').join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two panes of the full-screen Diff, as line text. */
|
||||||
|
function splitText(c: HTMLElement, pane: 'left' | 'right'): string[] {
|
||||||
|
return Array.from(c.querySelectorAll(`.split-pane.${pane} .ln-row`)).map((el) => (el.textContent ?? '').replace(/^\d*/, ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
|
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
|
||||||
@@ -104,6 +71,14 @@ async function boot(): Promise<HTMLElement> {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open one git row, then lift its pair into the full-screen Diff. */
|
||||||
|
async function openSplit(c: HTMLElement, label: 'Staged Changes' | 'Changes'): Promise<void> {
|
||||||
|
fireEvent.click(group(c, label)[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
fireEvent.click(Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Diff')!)
|
||||||
|
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
|
||||||
|
}
|
||||||
|
|
||||||
describe('a file that is staged and then edited again', () => {
|
describe('a file that is staged and then edited again', () => {
|
||||||
it('shows up in both groups', async () => {
|
it('shows up in both groups', async () => {
|
||||||
const c = await boot()
|
const c = await boot()
|
||||||
@@ -113,31 +88,50 @@ describe('a file that is staged and then edited again', () => {
|
|||||||
|
|
||||||
it('Diff on the staged row compares HEAD with the staged copy', async () => {
|
it('Diff on the staged row compares HEAD with the staged copy', async () => {
|
||||||
const c = await boot()
|
const c = await boot()
|
||||||
fireEvent.click(group(c, 'Staged Changes')[0])
|
await openSplit(c, 'Staged Changes')
|
||||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
expect(splitText(c, 'left')).toEqual(['a', 'b', 'c'])
|
||||||
const text = viewText(c)
|
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c'])
|
||||||
expect(text).toContain('b')
|
// The later edit is not part of what is staged, so neither side may show it.
|
||||||
expect(text).toContain('STAGED')
|
expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING')
|
||||||
// The later edit is not part of what is staged, so it must not show here.
|
|
||||||
expect(text).not.toContain('AFTER-STAGING')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Diff on the unstaged row compares the staged copy with disk', async () => {
|
it('Diff on the unstaged row compares the staged copy with disk', async () => {
|
||||||
const c = await boot()
|
const c = await boot()
|
||||||
fireEvent.click(group(c, 'Changes')[0])
|
await openSplit(c, 'Changes')
|
||||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
|
||||||
const text = viewText(c)
|
|
||||||
expect(text).toContain('AFTER-STAGING')
|
|
||||||
// 'b' was already replaced before staging, so this half must not mention it.
|
// 'b' was already replaced before staging, so this half must not mention it.
|
||||||
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
|
expect(splitText(c, 'left')).toEqual(['a', 'STAGED', 'c', ''])
|
||||||
|
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the picked panel behind Actual always reaches back to HEAD', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
fireEvent.click(group(c, 'Changes')[0])
|
||||||
|
const scroll = await waitFor(() => {
|
||||||
|
const el = c.querySelector<HTMLElement>('.ce-scroll')
|
||||||
|
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
|
||||||
|
return el
|
||||||
|
})
|
||||||
|
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
|
||||||
|
fireEvent.click(scroll, { clientY: 10 + 20 + 5 })
|
||||||
|
const peek = await waitFor(() => {
|
||||||
|
const p = c.querySelector<HTMLElement>('.peek')
|
||||||
|
if (!p) throw new Error('peek not ready')
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
|
||||||
|
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
|
||||||
|
|
||||||
|
// A click anywhere off the code drops it.
|
||||||
|
fireEvent.mouseDown(document.body)
|
||||||
|
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('labels which pair the diff is comparing', async () => {
|
it('labels which pair the diff is comparing', async () => {
|
||||||
const c = await boot()
|
const c = await boot()
|
||||||
fireEvent.click(group(c, 'Staged Changes')[0])
|
await openSplit(c, 'Staged Changes')
|
||||||
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
|
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged')
|
||||||
fireEvent.click(group(c, 'Changes')[0])
|
await openSplit(c, 'Changes')
|
||||||
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
|
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('staged → actual')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
|
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
|
||||||
|
|||||||
35
test/group-by-dir.test.ts
Normal file
35
test/group-by-dir.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { groupByDir } from '../src/renderer/src/components'
|
||||||
|
import type { Change } from '../src/renderer/src/types'
|
||||||
|
|
||||||
|
function change(path: string): Change {
|
||||||
|
return { path, status: 'M', add: 1, del: 0, deleted: false, staged: false, id: path }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('groupByDir', () => {
|
||||||
|
it('gives one group per folder when git returns the folder in separated runs', () => {
|
||||||
|
const groups = groupByDir([
|
||||||
|
change('tests/Feature/AlphaTest.php'),
|
||||||
|
change('app/Models/User.php'),
|
||||||
|
change('tests/Feature/BetaTest.php'),
|
||||||
|
])
|
||||||
|
expect(groups.map((g) => g.dir)).toEqual(['tests/Feature', 'app/Models'])
|
||||||
|
expect(groups[0].rows.map((r) => r.path)).toEqual([
|
||||||
|
'tests/Feature/AlphaTest.php',
|
||||||
|
'tests/Feature/BetaTest.php',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every row, so the keyboard cursor walks the full list', () => {
|
||||||
|
const list = ['a/one.ts', 'b/two.ts', 'a/three.ts', 'four.ts'].map(change)
|
||||||
|
const flat = groupByDir(list).flatMap((g) => g.rows)
|
||||||
|
expect(flat).toHaveLength(list.length)
|
||||||
|
expect(new Set(flat.map((r) => r.id)).size).toBe(list.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a project-root file in the empty-string group', () => {
|
||||||
|
expect(groupByDir([change('README.md')])).toEqual([
|
||||||
|
{ dir: '', rows: [change('README.md')] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -70,4 +70,31 @@ describe('renderMarkdown', () => {
|
|||||||
expect(html).not.toContain('<table')
|
expect(html).not.toContain('<table')
|
||||||
expect(html).toContain('<p>a | b not a table</p>')
|
expect(html).toContain('<p>a | b not a table</p>')
|
||||||
})
|
})
|
||||||
|
it('resolves a relative image against the document folder', () => {
|
||||||
|
const html = renderMarkdown('', 'docs/how-to')
|
||||||
|
expect(html).toContain('data-src="docs/how-to/forge-app-config.png"')
|
||||||
|
expect(html).not.toContain(' src=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the image title out of the path and walks up a parent folder', () => {
|
||||||
|
const html = renderMarkdown('', 'docs/how-to')
|
||||||
|
expect(html).toContain('data-src="docs/img/a b.png"')
|
||||||
|
expect(html).toContain('title="A title"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads a leading slash as the project root', () => {
|
||||||
|
expect(renderMarkdown('', 'docs/how-to')).toContain('data-src="img/a.png"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a remote image on src', () => {
|
||||||
|
const html = renderMarkdown('', 'docs')
|
||||||
|
expect(html).toContain('src="https://x.com/a.png"')
|
||||||
|
expect(html).not.toContain('data-src')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a link title out of the href', () => {
|
||||||
|
const html = renderMarkdown('[a](https://x.com "Home")')
|
||||||
|
expect(html).toContain('href="https://x.com"')
|
||||||
|
expect(html).toContain('title="Home"')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
63
test/stub-bridge.ts
Normal file
63
test/stub-bridge.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/* Minimal preload bridge for the renderer tests: real git rows over a flat file
|
||||||
|
* list. Without `window.helder` the store falls back to the mock project, so a
|
||||||
|
* test that needs its own git payload has to install one of these first. */
|
||||||
|
import { DEFAULT_CONFIG } from '../src/renderer/src/types'
|
||||||
|
import type { GitStatus } from '../src/renderer/src/types'
|
||||||
|
|
||||||
|
export interface StubRow {
|
||||||
|
path: string
|
||||||
|
status: GitStatus
|
||||||
|
staged: boolean
|
||||||
|
original: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install the bridge. `files` is the working tree: one entry per path. */
|
||||||
|
export function installBridge(rows: StubRow[], files: Record<string, string>): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
const children = Object.keys(files).map((path) => ({ name: path, type: 'file' as const, path }))
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children }),
|
||||||
|
readDir: async () => [],
|
||||||
|
files: async () => files,
|
||||||
|
read: async (path: string) => files[path] ?? '',
|
||||||
|
imageDataUrl: async () => '',
|
||||||
|
write: async () => {},
|
||||||
|
delete: async () => {},
|
||||||
|
create: async () => {},
|
||||||
|
mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: { read: async () => '', write: async () => {} },
|
||||||
|
git: {
|
||||||
|
load: async () => ({ branch: 'main', changes: rows }),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
symbols: { names: async () => [], lookup: async () => ({ name: '', defs: [], refs: [], refCount: 0 }) },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onFullscreen: off,
|
||||||
|
onProjectChanged: off,
|
||||||
|
onConfigChanged: off,
|
||||||
|
onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeBridge(): void {
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user