Compare commits
50 Commits
2e84cbd0fa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d1c5df83 | |||
| f6a551d7c4 | |||
| 1ebb9a0e74 | |||
| 008adbfdb6 | |||
| bb012702fe | |||
| 2987794a9b | |||
| 3746374d94 | |||
| bccc97c11c | |||
| e5f739ec24 | |||
| 47581d5718 | |||
| 79c7a45807 | |||
| 03e1f90515 | |||
| 37d15a29f0 | |||
| 74cffe2253 | |||
| fa4d1d7db5 | |||
| 1b212e2fb6 | |||
| 3a941480a9 | |||
| e126182ae6 | |||
| daf8945da7 | |||
| 03e16d49a1 | |||
| d3bcdb74c2 | |||
| 6c3a021bb9 | |||
| 43131915c0 | |||
| 73bfd2b86a | |||
| ab6f09bde2 | |||
| 7f5d1a0d03 | |||
| fb4018336f | |||
| 6114ce440d | |||
| 5e5fc53dde | |||
| 0a90ab822f | |||
| 6beef86506 | |||
| 513af0e164 | |||
| e55f4e714e | |||
| 42defcc7cd | |||
| 16296a27da | |||
| 4b4676a673 | |||
| 29c90725ae | |||
| 7c69853e85 | |||
| d5dbf7187d | |||
| 87fcf9bf93 | |||
| 8fc6478bb1 | |||
| 7bac3fe7c5 | |||
| 6a940b9b7a | |||
| 0879f51f48 | |||
| bd466e84a8 | |||
| 299ae17d80 | |||
| 6abee5f8b6 | |||
| 66248c4736 | |||
| 3f5078841d | |||
| 3d77fdfeff |
20
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.playwright-mcp/
|
||||
2
.helder/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Helder — local, machine-specific state (do not commit).
|
||||
*
|
||||
61
.helder/config.default.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"ai": {
|
||||
"command": "claude",
|
||||
"autoLaunch": true
|
||||
},
|
||||
"editor": {
|
||||
"autoSave": false,
|
||||
"tabSize": 4,
|
||||
"wordWrap": "markdown"
|
||||
},
|
||||
"git": {
|
||||
"confirmDiscard": true,
|
||||
"confirmStage": false,
|
||||
"confirmUnstage": false,
|
||||
"defaultDiffMode": "updated",
|
||||
"refreshInterval": 10000
|
||||
},
|
||||
"files": {
|
||||
"exclude": [],
|
||||
"followGitignore": false
|
||||
},
|
||||
"terminal": {
|
||||
"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": {
|
||||
"restoreOnLaunch": true
|
||||
}
|
||||
}
|
||||
14
.helder/theme.css
Normal file
@@ -0,0 +1,14 @@
|
||||
/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
* This file is created once and never overwritten; edit it freely.
|
||||
* The code font and font size live here (not in config.json). Uncomment and
|
||||
* tweak any variable below; you can also override any --token from the built-in
|
||||
* theme (see the design tokens in the app's styles). */
|
||||
:root {
|
||||
/* Code surfaces (editor + terminals) */
|
||||
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
||||
/* --code-size: 13px; */ /* editor font size */
|
||||
/* --term-size: 12.5px; */ /* terminal font size */
|
||||
|
||||
/* Example accent override: */
|
||||
/* --accent: #4d8dff; */
|
||||
}
|
||||
7
.playwright-mcp/console-2026-06-15T08-02-31-469Z.log
Normal file
@@ -0,0 +1,7 @@
|
||||
[ 282ms] [INFO] You are running a development build of Vue.
|
||||
Make sure to use the production build (*.prod.js) when deploying for production. @ https://unpkg.com/vue@3/dist/vue.global.js:12834
|
||||
[ 292ms] Identifier 'ref' has already been declared
|
||||
[ 293ms] Identifier 'ref' has already been declared
|
||||
[ 293ms] Identifier 'ref' has already been declared
|
||||
[ 293ms] Identifier 'ref' has already been declared
|
||||
[ 298ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8753/favicon.ico:0
|
||||
0
.playwright-mcp/page-2026-06-15T08-02-31-796Z.yml
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 140,
|
||||
"tabWidth": 2
|
||||
}
|
||||
66
CLAUDE.md
@@ -4,7 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project state
|
||||
|
||||
This is a **greenfield project**. No application code, build setup, or `package.json` exists yet — only a design handoff. The first real task is to scaffold the Electron app and port the prototype. Until then, treat the two handoff documents as the contract:
|
||||
**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`):
|
||||
- **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 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.
|
||||
|
||||
- **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.
|
||||
|
||||
- **Search** — ripgrep (`@vscode/ripgrep`, bundled binary) for content (`--json`, fixed-string smart-case) and the file-name list (`--files`), via `src/main/search-service.ts`. `SearchModal` calls it debounced and falls back to the in-memory index when `window.helder` is absent.
|
||||
|
||||
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 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).
|
||||
|
||||
Not yet done: packaging (electron-builder → .app/.dmg) — `out/` is dev build output only, there is no distributable yet.
|
||||
|
||||
The two handoff documents remain the contract:
|
||||
|
||||
- **`DESIGN.md`** — functional/UX source of truth. Every panel, interaction, state, and edge case at the behavior level. Read this for *what the app does*.
|
||||
- **`design_handoff_helder_workbench/README.md`** — technical source of truth. Structure, design tokens, recommended stack, real-integration mechanics, and the suggested implementation order. Read this for *how to build it*.
|
||||
@@ -20,16 +39,20 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
|
||||
- Electron (latest stable), main + renderer + preload bridge with `contextIsolation: true`.
|
||||
- Renderer: React 18 + TypeScript + Vite (`electron-vite` scaffold). Prototype is already React, so component structure ports directly.
|
||||
- Syntax highlighting: Prism 1.29 (or swap to Shiki/CodeMirror 6; token→color mapping is documented in README).
|
||||
- Fonts: UI = system stack; code/mono = **JetBrains Mono bundled locally** (never Google Fonts CDN in Electron).
|
||||
- Fonts: UI = **IBM Plex Sans**; code/mono/labels = **IBM Plex Mono**. Both bundled locally (never Google Fonts CDN in Electron).
|
||||
|
||||
## Architecture rules and gotchas (these will bite if ignored)
|
||||
|
||||
- **Renderer never touches the filesystem, git, or PTYs directly.** All FS (`fs` + `chokidar`), git (`git` / `simple-git`), search (`rg` + fuzzy), terminals (`node-pty` + `xterm.js`), and clipboard go through the main process via IPC / the preload bridge. The prototype keeps all state in the top `App` component; in the real app, lift FS/git/terminal state into main and stream over IPC.
|
||||
- **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.
|
||||
- **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. 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask)
|
||||
|
||||
@@ -53,20 +76,49 @@ Settings are project-scoped, living in a `.helder/` folder in the opened project
|
||||
- Effective value = `config.json` if present, else `config.default.json`, merged key by key.
|
||||
- `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files.
|
||||
|
||||
## Logging & crash diagnostics
|
||||
|
||||
One file, `~/Library/Logs/Helder/helder.log` (rotates at 2 MB, keeps 3), written **synchronously** so a line survives the process dying right after it. Reachable from **Help → Open Log** and from the crash panel's *Open Log* button. Main and renderer both write to it, so a failure reads as one chronological story.
|
||||
|
||||
- `src/main/logger.ts` — the sink. Deliberately imports NO electron so it stays unit-testable (`test/logger.test.ts`); `initLogger({dir})` is handed the path by the caller. Every process logs its pid, since sibling project windows share the file.
|
||||
- `src/main/diagnostics.ts` — `initDiagnostics()` runs **before** `app.whenReady()` (crashReporter must start early; `app.setName` must precede `app.getPath('logs')` or logs land in `~/Library/Logs/Electron`). Hooks `uncaughtException`, `unhandledRejection`, `render-process-gone` (the blank-window crash), `child-process-gone`, `preload-error`, `unresponsive`, and renderer console warnings/errors. Native minidumps (node-pty can segfault) go to `app.getPath('crashDumps')`, local only — nothing is uploaded.
|
||||
- `src/main/index.ts` — the `handle()` / `on()` wrappers around `ipcMain`: every IPC failure is logged with channel + args, then **rethrown** so renderer behaviour is unchanged. Calls over 1 s log a `slow` warning. Note both wrappers must call `ipcMain.handle`/`ipcMain.on` — a rename that rewrites those lines makes the wrappers infinitely recursive and silently registers **no handlers at all** (every IPC then fails with "No handler registered").
|
||||
- `src/renderer/src/log.ts` — `rlog` + `installErrorLogging()` (window `error`, `unhandledrejection`). Called from `main.tsx` before first render. `ErrorBoundary` logs the component stack, which exists nowhere else.
|
||||
|
||||
Keep warnings honest: an expected event must not log as WARN (see `killing` in `pty-service.ts` — deliberate kills log INFO). A log full of false alarms is a log nobody reads.
|
||||
|
||||
## Design tokens
|
||||
|
||||
Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a` → `--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle.
|
||||
Canonical source is **`docs/design/README.md`** plus the board `docs/design/Helder IDE.dc.html` (all screens at real pixel size) and `docs/design/screenshots/`. The tokens live in the `:root` block of `src/renderer/src/styles.css`.
|
||||
|
||||
Four surfaces only: `--bg-0` editor/terminal `#101720` · `--bg-2` panels/chrome `#18202B` · `--sel`/`--border` `#232C39` · `--hover` `#1E2733`. Text ramp `#F4F5F4` → `#E4E7E6` → `#BAC0C0` → `#6C7783` → `#3A424C`. One accent, amber `#E8913A` (deep `#C4741F`, soft `#F0B476`), reserved for action, state and signal — never a decorative fill. Diff is teal `#8FBFB4` added and amber-deep `#C4741F` removed: **no green, no red anywhere**. Six muted syntax colours, all weight 400.
|
||||
|
||||
Radii: 2px controls · 4px panels/menus · 8px overlays only. Row heights: 24px explorer · 26px source control · 28px status bar · 30px menu item · 34px view strip · 44px title bar. Hairlines are always 1px `#232C39`; the selection marker is a 2px amber rule.
|
||||
|
||||
Row states: hover fills one step and does nothing else; selected takes `--sel` plus the 2px amber rule. Focus is always the 2px amber border plus a 2px 22% ring. Motion is colour-only, 160ms; the one exception is `blink` (stepped 1.1s) on the caret, the terminal cursor and the period in the wordmark. Respect `prefers-reduced-motion`.
|
||||
|
||||
The older `design_handoff_helder_workbench/` prototype is superseded by `docs/design` — read it only for behaviour that the new board does not cover.
|
||||
|
||||
## Suggested implementation order (from README)
|
||||
|
||||
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle JetBrains Mono.
|
||||
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
|
||||
2. Static layout: four resizable columns + title/status bars.
|
||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
||||
4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split.
|
||||
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).
|
||||
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).
|
||||
|
||||
## Commands
|
||||
|
||||
No build/lint/test commands exist yet. Once the Electron + Vite toolchain is scaffolded, document the real `dev` / `build` / `lint` / `test` commands here, replacing this note.
|
||||
- `npm run dev` — launch the app in Electron with HMR (`electron-vite dev`).
|
||||
- `npm run build` — type-stripped production build into `out/` (`electron-vite build`). A frontend change is not done until this succeeds.
|
||||
- `npm run preview` / `npm start` — run the built app (`electron-vite preview`).
|
||||
- `npm run typecheck` — `tsc --noEmit` over the renderer (`tsconfig.web.json`) and main/preload (`tsconfig.node.json`). The build itself uses esbuild and does NOT type-check, so run this separately to catch type errors.
|
||||
- `npm test` — vitest suite in `test/` (pure logic + node-side services: diff, fuzzy, highlight, config, fs, git). `npm run test:watch` for watch mode.
|
||||
- `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied).
|
||||
- `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged.
|
||||
|
||||
**The mac build must be ad-hoc signed — `build/adhoc-sign.cjs` (the `afterPack` hook) does this.** `mac.identity: null` skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports `Identifier=Electron`, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with *"Malware Blocked and Moved to Trash"*. A real ad-hoc signature over the whole bundle (with `build/entitlements.mac.plist` for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with `xattr -dr com.apple.quarantine /Applications/Helder.app` or ship a Developer ID build.
|
||||
|
||||
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.
|
||||
|
||||
34
DESIGN.md
@@ -64,19 +64,20 @@ Two stacked sections, each with its own header and count:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- Rows have default, hover, and selected states. The selected row is the file currently open in the editor.
|
||||
|
||||
### 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:
|
||||
- In CHANGES (unstaged): **Stage file**, and **Discard changes**.
|
||||
- In STAGED: **Unstage file**.
|
||||
- In CHANGES (unstaged): **Open diff**, **Stage file**, and **Discard changes**.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -124,23 +125,26 @@ The third and widest column. Tabs on top, a view toolbar under them, and the cod
|
||||
A row under the tabs:
|
||||
|
||||
- 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
|
||||
|
||||
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.
|
||||
2. **Updated**: the current, editable version of the file. Added and changed lines get a green bar in the left gutter.
|
||||
3. **Diff**: a single pane, unified inline diff. Removed lines are red with a `-`, added lines are green with a `+`, shown in sequence.
|
||||
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.
|
||||
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. **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**: 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. **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
|
||||
|
||||
- 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).
|
||||
- **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
|
||||
|
||||
@@ -151,8 +155,8 @@ Right-clicking inside the code area, with or without a selection, shows a contex
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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. 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.
|
||||
|
||||
---
|
||||
@@ -185,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.
|
||||
|
||||
- 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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
227
README.md
@@ -1,95 +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: greenfield.** This repository currently contains the design handoff only — no application code yet. The first task is to scaffold the Electron app and port the prototype. 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
|
||||
|
||||
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.)
|
||||
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).
|
||||
4. **Right column** — a live **Claude Code agent** terminal stacked over a normal shell terminal.
|
||||
| Column | What it holds |
|
||||
|---|---|
|
||||
| **A · Source Control** | Commit box, staged list, changes list, per-file `+/−` counts |
|
||||
| **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
|
||||
|
||||
`⌘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 |
|
||||
|-------|--------|
|
||||
| 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 |
|
||||
Press `?` in the title bar for the full list.
|
||||
|
||||
**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
|
||||
|
||||
> No build tooling exists yet. This section will be filled in once the Electron + Vite toolchain is scaffolded.
|
||||
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.
|
||||
|
||||
Planned implementation order:
|
||||
**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.
|
||||
|
||||
1. Electron shell + frameless dark window; port design 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` → staging + commit → 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.
|
||||
|
||||
To preview the design prototype now, open `design_handoff_helder_workbench/design/Helder - AI Code Workbench.html` in a browser — it's a clickable React-via-Babel mock with sample data.
|
||||
That is also why the app exists. The workflow needed a tool, so the workflow built one.
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
- **`.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.
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `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
|
||||
|
||||
```
|
||||
DESIGN.md Functional/UX spec — every panel, state, and interaction
|
||||
CLAUDE.md Guidance for Claude Code working in this repo
|
||||
design_handoff_helder_workbench/
|
||||
README.md Technical handoff — structure, design tokens, integration mechanics
|
||||
design/
|
||||
Helder - AI Code Workbench.html Clickable prototype (open in a browser)
|
||||
styles.css Canonical design tokens (the :root block)
|
||||
src/*.jsx Prototype components (reference only — replace mock data)
|
||||
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
|
||||
```
|
||||
|
||||
The two handoff documents are the source of truth: **`DESIGN.md`** for *what the app does*, **`design_handoff_helder_workbench/README.md`** for *how to build it*. The prototype is a visual reference — do not ship it as-is.
|
||||
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/
|
||||
main/ window, IPC, services (fs · git · pty · search · config · logger)
|
||||
preload/ the contextIsolation bridge — the only renderer↔OS surface
|
||||
renderer/ React UI: App, editor, terminals, overlays, project store, helpers
|
||||
test/ vitest — diff, fuzzy, highlight, config, fs, git, grouping, UI
|
||||
docs/design/ the design board, tokens and screens at real pixel size
|
||||
DESIGN.md functional spec: every panel, state and interaction
|
||||
CLAUDE.md architecture notes and the traps, for the next agent session
|
||||
```
|
||||
|
||||
`docs/design/README.md` is the visual source of truth. `DESIGN.md` is the behavioural one.
|
||||
|
||||
---
|
||||
|
||||
|
||||
44
build/adhoc-sign.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
// Ad-hoc sign the macOS bundle after electron-builder packs it.
|
||||
//
|
||||
// Why this exists: `mac.identity: null` tells electron-builder to skip signing
|
||||
// altogether. The .app then keeps only the linker signature that Apple put on
|
||||
// the prebuilt Electron binary. That signature says `Identifier=Electron`,
|
||||
// seals no resources, and does not bind our Info.plist. macOS reads a bundle
|
||||
// like that as tampered-with and shows "Malware Blocked and Moved to Trash".
|
||||
//
|
||||
// A real ad-hoc signature over the whole bundle fixes it. The app stays
|
||||
// unsigned in the Developer ID sense (no notarization, so a *downloaded* copy
|
||||
// still needs the quarantine flag cleared), but it is no longer flagged as
|
||||
// malware and runs fine locally.
|
||||
//
|
||||
// Replace this with a Developer ID identity + notarization when the app ships.
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
exports.default = async function adhocSign(context) {
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const appName = context.packager.appInfo.productFilename
|
||||
const appPath = path.join(context.appOutDir, `${appName}.app`)
|
||||
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
|
||||
|
||||
console.log(` • ad-hoc signing ${appPath}`)
|
||||
execFileSync(
|
||||
'codesign',
|
||||
[
|
||||
'--force',
|
||||
'--deep',
|
||||
'--sign',
|
||||
'-',
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
entitlements,
|
||||
appPath
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Fail the build rather than ship a bundle macOS will quarantine again.
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' })
|
||||
}
|
||||
17
build/entitlements.mac.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- V8 compiles JavaScript at runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Electron sets dyld vars when it spawns its own helpers. -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- node-pty is a native addon signed with a different (ad-hoc) identity. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
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
@@ -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,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); }
|
||||
873
docs/design/Helder IDE.dc.html
Normal file
@@ -0,0 +1,873 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/fonts.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/colors.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/typography.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/spacing.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/radius.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/elevation.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/motion.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/tokens/base.css">
|
||||
<link rel="stylesheet" href="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/styles.css">
|
||||
<script src="_ds/blijnder-e2fd6583-1c87-4e16-bc91-117f035adbbc/_ds_bundle.js"></script>
|
||||
<style>
|
||||
body { margin: 0; background: #EFF0EE; }
|
||||
a { color: #C4741F; text-decoration: none; }
|
||||
a:hover { color: #171C22; }
|
||||
@keyframes blink { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<section style="background:#EFF0EE;padding:64px 56px 96px;display:flex;flex-direction:column;gap:72px;font-family:'IBM Plex Sans',sans-serif;color:#171C22">
|
||||
|
||||
<header style="display:flex;flex-direction:column;gap:20px;max-width:1140px">
|
||||
<div style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">[ 00 ] design canvas · helder</div>
|
||||
<h1 style="margin:0;font:700 58px/1.02 'IBM Plex Mono',monospace;letter-spacing:-.04em;color:#171C22">helder<span style="color:#E8913A;animation:blink 1.1s steps(1,end) infinite">.</span></h1>
|
||||
<p style="margin:0;max-width:68ch;font:400 17px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">An editor for working with Claude Code. One slate shell, one amber accent, IBM Plex Mono for everything that is code or label. Every layer below uses the same four surfaces, the same 24px row height and the same selection marker: 2px amber on the left, fill one step deeper.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;padding-top:4px">
|
||||
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#171C22;background:rgba(232,145,58,.10);border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">workspace</span>
|
||||
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">overlays</span>
|
||||
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">menus</span>
|
||||
<span style="font:600 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#3A424C;border:1px solid #CDD1CD;border-radius:2px;padding:7px 10px">foundations</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ===================== 01 · WORKSPACE ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 01 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Workspace</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Four columns: source control · explorer · editor · agent. 1760 × 1000</span>
|
||||
</div>
|
||||
|
||||
<div data-screen-label="01 Workspace" style="width:1760px;height:1000px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column;color:#E4E7E6">
|
||||
|
||||
<!-- titlebar -->
|
||||
<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">odynt</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">main</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>resources</span><span>›</span><span>views</span><span>›</span><span>admin</span><span>›</span><span>vaults</span><span>›</span>
|
||||
<span style="color:#F4F5F4;font-weight:600">form.blade.php</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;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:#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">
|
||||
|
||||
<!-- source control -->
|
||||
<div style="width:236px;flex:none;background:#18202B;border-right:1px solid #232C39;display:flex;flex-direction:column">
|
||||
<div style="padding:12px;display:flex;gap:8px;align-items:stretch;border-bottom:1px solid #232C39">
|
||||
<div style="flex:1;min-width:0;height:52px;background:#101720;border:1px solid #232C39;border-radius:2px;padding:8px 9px;font:400 12px/1.5 'IBM Plex Sans',sans-serif;color:#6C7783">Shift+Enter to commit</div>
|
||||
<div style="width:34px;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">4</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column">
|
||||
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;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 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">category.blade.php</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 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:500 12px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">form.blade.php</span></div>
|
||||
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;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 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">subcategory.blade.php</span></div>
|
||||
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;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 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">declined-names.blade.php</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">1</span>
|
||||
</div>
|
||||
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 12px 0 10px;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 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">declined-names.blade.php</span></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">4 staged · 1 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:30px;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>odynt</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">
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>app</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>blueprints</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>bootstrap</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>config</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>database</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>docs</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>lang</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>public</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#6C7783">▤</span>resources</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:#6C7783">▤</span>css</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:#6C7783">▤</span>js</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:#6C7783">▤</span>markdown</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:#6C7783">▤</span>views</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 44px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#6C7783">▤</span>admin</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#F4F5F4"><span style="color:#E8913A;width:8px">⌄</span><span style="color:#6C7783">▤</span>vaults</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>_partials</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>category.blade.php<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 74px;color:#F4F5F4;background:#232C39;border-left:2px solid #E8913A"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font-weight:500">form.blade.php</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 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>index.blade.php</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 76px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>subcategory.blade.php<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="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>components</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>conversation</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>help</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px 0 60px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>settings</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>routes</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="color:#6C7783;width:8px">›</span><span style="color:#6C7783">▤</span>tests</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>AGENTS.md</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>CLAUDE.md</div>
|
||||
<div style="height:24px;display:flex;align-items:center;gap:7px;padding:0 12px;color:#BAC0C0"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>vite.config.js</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- editor -->
|
||||
<div style="flex:1;min-width:0;background:#101720;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:14px;padding:0 14px;border-bottom:1px solid #232C39">
|
||||
<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">+1</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#C4741F">−1</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:#171720;background:#E8913A;color:#171C22">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">Diff</span>
|
||||
<span style="padding:5px 12px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Split</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;overflow:hidden;display:grid;grid-template-columns:56px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding-top:10px">
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">1</span><span style="color:#6C7783">{{--</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">2</span><span style="padding-left:31.3px;color:#6C7783">4B — the Vault editor. `$vault === null` means create.</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">3</span><span style="color:#6C7783">--}}</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">4</span><span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">5</span><span style="color:#C3A6CE">@php</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">6</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$isCreate</span> <span style="color:#6C7783">=</span> <span style="color:#BAC0C0">$vault</span> <span style="color:#C3A6CE">===</span> <span style="color:#F0B476">null</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">7</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$teamClass</span> <span style="color:#6C7783">=</span> <span style="color:#8FBFB4">\App\Models\Team::class</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">8</span><span style="padding-left:31.3px"><span style="color:#BAC0C0">$userClass</span> <span style="color:#6C7783">=</span> <span style="color:#8FBFB4">\App\Models\User::class</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">9</span><span style="color:#C3A6CE">@endphp</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">10</span><span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">11</span><span><span style="color:#6C7783"><</span><span style="color:#F0B476">x-layouts.app</span> <span style="color:#BAC0C0">:title</span>=<span style="color:#8FBFB4">"$vault?->name"</span><span style="color:#6C7783">></span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">12</span><span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">13</span><span style="padding-left:31.3px"><span style="color:#6C7783"><</span><span style="color:#F0B476">style</span><span style="color:#6C7783">></span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">14</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-head</span> {</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">15</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">display</span>: <span style="color:#8FBFB4">flex</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">16</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">align-items</span>: <span style="color:#8FBFB4">flex-end</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">17</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">gap</span>: <span style="color:#F0B476">20</span>px;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">18</span><span style="padding-left:62.6px">}</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">19</span><span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">20</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-card</span> {</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">21</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--color-surface-raised</span>);</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">22</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">border</span>: <span style="color:#F0B476">1</span>px <span style="color:#8FBFB4">solid</span> <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--color-border-default</span>);</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">23</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">border-radius</span>: <span style="color:#F0B476">4</span>px;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">24</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">padding</span>: <span style="color:#F0B476">22</span>px <span style="color:#F0B476">24</span>px;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">25</span><span style="padding-left:62.6px">}</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">26</span><span></span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">27</span><span style="padding-left:62.6px"><span style="color:#E4E7E6">.vlt-group-label</span> {</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">28</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">font-family</span>: <span style="color:#8FBFB4">'IBM Plex Mono'</span>, monospace;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">29</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">letter-spacing</span>: <span style="color:#F0B476">.14</span>em;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">30</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">text-transform</span>: <span style="color:#8FBFB4">uppercase</span>;</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">31</span><span style="padding-left:62.6px">}</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">32</span><span style="padding-left:31.3px"><span style="color:#6C7783"></</span><span style="color:#F0B476">style</span><span style="color:#6C7783">></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- agent -->
|
||||
<div style="width:480px;flex:none;background:#18202B;border-left:1px solid #232C39;display:flex;flex-direction:column">
|
||||
<div style="padding:16px;border-bottom:1px solid #232C39;display:flex;gap:14px">
|
||||
<div style="width:60px;height:60px;flex:none;background:#232C39;border-radius:2px;display:flex;align-items:center;justify-content:center;font:700 20px/1 'IBM Plex Mono',monospace;color:#E8913A">◆</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;min-width:0">
|
||||
<span style="font:700 14px/1.2 'IBM Plex Mono',monospace;color:#F4F5F4">Claude Code <span style="color:#6C7783;font-weight:400">v2.1.259</span></span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#BAC0C0">Opus 5 (1M context) · Claude Max</span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Mono',monospace;color:#6C7783">~/code/odynt</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px;display:flex;gap:10px;align-items:center;background:rgba(232,145,58,.10);border-bottom:1px solid #232C39">
|
||||
<span style="font:600 12px/1 'IBM Plex Mono',monospace;color:#E8913A">[ ! ]</span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#E4E7E6">1 MCP server needs authentication — run <span style="font-family:'IBM Plex Mono',monospace;color:#F0B476">/mcp</span></span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;padding:16px;display:flex;flex-direction:column;gap:14px;overflow:hidden">
|
||||
<div style="display:flex;gap:10px">
|
||||
<span style="font:400 13px/1.6 'IBM Plex Mono',monospace;color:#E8913A">›</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#E4E7E6">Implement the vault admin screens from docs/design_handoff_vaults/README.md.</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<span style="font:400 13px/1.6 'IBM Plex Mono',monospace;color:#6C7783">◆</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#BAC0C0">I read the handoff and the existing policies. Then I write the tests first.</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:12px;background:#101720;border:1px solid #232C39;border-radius:2px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#6C7783">
|
||||
<span><span style="color:#8FBFB4">Read</span> docs/design_handoff_vaults/README.md <span style="color:#6C7783">· 412 lines</span></span>
|
||||
<span><span style="color:#8FBFB4">Grep</span> "VaultPolicy" <span style="color:#6C7783">· 7 hits</span></span>
|
||||
<span><span style="color:#F0B476">Edit</span> app/Policies/VaultPolicy.php</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin:0 16px;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="font:400 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">test the round trip with Playwright</span>
|
||||
<span style="width:1px;height:16px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
|
||||
</div>
|
||||
<div style="padding:10px 16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
|
||||
<span>[Opus 5 · 1M]</span>
|
||||
<span style="color:#171C22;background:#E8913A;border-radius:2px;padding:3px 6px;font-weight:600">odynt</span>
|
||||
<span>main</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="color:#E8913A">▸▸ auto mode</span>
|
||||
<span>/rc</span>
|
||||
</div>
|
||||
<div style="height:190px;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:#8FBFB4">[main]</span><span style="color:#6C7783">[~/code/odynt]$</span> php artisan test --filter=Vault</div>
|
||||
<div style="color:#6C7783">PASS Tests\Feature\VaultAdminTest · 14 assertions</div>
|
||||
<div><span style="color:#8FBFB4">[main]</span><span style="color:#6C7783">[~/code/odynt]$</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>
|
||||
|
||||
<!-- status bar -->
|
||||
<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">⑂ main</span>
|
||||
<span style="color:#8FBFB4">+16</span><span style="color:#C4741F">−12</span>
|
||||
<span style="flex:1"></span>
|
||||
<span>resources/views/admin/vaults/form.blade.php</span>
|
||||
<span>·</span><span>Ln 20, Col 12</span><span>·</span><span>UTF-8</span><span>·</span><span>LF</span><span>·</span><span style="color:#BAC0C0">Blade</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 02 · OVERLAYS ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 02 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Overlays</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">One rule: ink scrim, panel on slate-2, 8px radius, esc top right.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
|
||||
|
||||
<!-- search -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2a · search ⌘f</div>
|
||||
<div data-screen-label="02a Search" style="width:1180px;height:700px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
|
||||
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="height:54px;flex:none;display:flex;align-items:center;gap:12px;padding:0 18px;border-bottom:1px solid #232C39">
|
||||
<span style="font:400 14px/1 'IBM Plex Mono',monospace;color:#E8913A">⌕</span>
|
||||
<span style="font:500 16px/1 'IBM Plex Mono',monospace;color:#F4F5F4">config(</span>
|
||||
<span style="width:1px;height:18px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:4px 6px">php</span>
|
||||
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#BAC0C0;border:1px solid #232C39;border-radius:2px;padding:4px 6px">md</span>
|
||||
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#BAC0C0;border:1px solid #232C39;border-radius:2px;padding:4px 6px">js</span>
|
||||
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783;border-left:1px solid #232C39;padding-left:12px">0 here · 226 hits · 0 files</span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;display:flex">
|
||||
<div style="width:230px;flex:none;border-right:1px solid #232C39;padding:12px 0;display:flex;flex-direction:column">
|
||||
<div style="padding:0 14px 10px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">this file</div>
|
||||
<div style="height:26px;display:flex;align-items:center;gap:8px;padding:0 14px"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span><span style="font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">ConfigSyncCommand.php</span></div>
|
||||
<div style="padding:12px 14px;font:400 12px/1.6 'IBM Plex Sans',sans-serif;color:#6C7783">No matches in this file.</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;padding:12px 0;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:0 16px 10px;display:flex;gap:10px;align-items:center"><span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#E8913A">project</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:3px 6px">226</span></div>
|
||||
<div style="padding:6px 16px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>cypress/e2e/Conversation/ConversationWelcome.php<span style="flex:1"></span><span style="color:#6C7783">4</span></div>
|
||||
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
|
||||
<span style="text-align:right;padding-right:14px;color:#E8913A;background:#232C39;border-left:2px solid #E8913A">80</span><span style="color:#E4E7E6;background:#232C39">function conversationSeed<span style="color:#171C22;background:#E8913A">Config</span>(): void</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">99</span><span style="color:#BAC0C0">conversationSeed<span style="color:#F0B476">Config</span>();</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">163</span><span style="color:#BAC0C0">conversationSeed<span style="color:#F0B476">Config</span>();</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>database/seeders/DatabaseSeeder.php<span style="flex:1"></span><span style="color:#6C7783">3</span></div>
|
||||
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">230</span><span style="color:#6C7783">* This row is consumed by AppData::resolveDefaultTheme<span style="color:#F0B476">Config</span>()</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">245</span><span style="color:#BAC0C0">'config' => self::guestTheme<span style="color:#F0B476">Config</span>(),</span>
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">257</span><span style="color:#BAC0C0">private static function guestTheme<span style="color:#F0B476">Config</span>(): array</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>cypress/e2e/blueprint-038-conversation-menu.cy.js<span style="flex:1"></span><span style="color:#6C7783">1</span></div>
|
||||
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">163</span><span style="color:#BAC0C0">cy.url().should('eq', Cypress.<span style="color:#F0B476">config</span>('baseUrl') + '/conversation');</span>
|
||||
</div>
|
||||
<div style="padding:14px 16px 6px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#BAC0C0;display:flex;gap:8px;align-items:center"><span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>blueprints/done/012-dialog-page/blueprint.md<span style="flex:1"></span><span style="color:#6C7783">1</span></div>
|
||||
<div style="display:grid;grid-template-columns:64px 1fr;font:400 12px/24px 'IBM Plex Mono',monospace">
|
||||
<span style="text-align:right;padding-right:14px;color:#3A424C">162</span><span style="color:#BAC0C0">— `<span style="color:#8FBFB4">config/ai.php</span>` — providers.anthropic.api_key</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:250px;flex:none;border-left:1px solid #232C39;padding:12px 0">
|
||||
<div style="padding:0 14px 10px;display:flex;gap:10px;align-items:center"><span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">files</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#6C7783">0</span></div>
|
||||
<div style="padding:0 14px;font:400 12px/1.6 'IBM Plex Sans',sans-serif;color:#6C7783">No file names match.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:38px;flex:none;border-top:1px solid #232C39;display:flex;align-items:center;gap:14px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
|
||||
<span><span style="color:#E8913A">⌘←</span> / <span style="color:#E8913A">⌘→</span> column</span>
|
||||
<span><span style="color:#E8913A">⌘↑</span> / <span style="color:#E8913A">⌘↓</span> row</span>
|
||||
<span><span style="color:#E8913A">↵</span> open</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- shortcuts -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2b · shortcuts ?</div>
|
||||
<div data-screen-label="02b Shortcuts" style="width:600px;height:700px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
|
||||
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="height:50px;flex:none;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
|
||||
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A">◇</span>
|
||||
<span style="font:700 14px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Keyboard shortcuts</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;padding:8px 0;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:10px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">navigate</div>
|
||||
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">F</span></span><span style="color:#E4E7E6;padding:6px 0">Search contents and names</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">↑↓</span></span><span style="color:#E4E7E6;padding:6px 0">Move through a list</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">↵</span></span><span style="color:#E4E7E6;padding:6px 0">Open the selected item</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">←→</span></span><span style="color:#E4E7E6;padding:6px 0">Column left or right</span>
|
||||
</div>
|
||||
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">agent</div>
|
||||
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">→</span></span><span style="color:#E4E7E6;padding:6px 0">Pass the selection to the agent</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">N</span></span><span style="color:#E4E7E6;padding:6px 0">Open the project note</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">P</span></span><span style="color:#E4E7E6;padding:6px 0">Pass on the whole note</span>
|
||||
</div>
|
||||
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">git</div>
|
||||
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">C</span></span><span style="color:#E4E7E6;padding:6px 0">Focus the commit message</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">↵</span></span><span style="color:#E4E7E6;padding:6px 0">Commit the staged files</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">G</span></span><span style="color:#E4E7E6;padding:6px 0">Source Control column on or off</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">M</span></span><span style="color:#E4E7E6;padding:6px 0">Cycle Actual · Original · Diff · Split</span>
|
||||
</div>
|
||||
<div style="padding:16px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">file</div>
|
||||
<div style="display:grid;grid-template-columns:92px 1fr;row-gap:2px;align-items:center;font:400 13px/1.4 'IBM Plex Sans',sans-serif;padding:0 18px">
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">S</span></span><span style="color:#E4E7E6;padding:6px 0">Save this file</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">W</span></span><span style="color:#E4E7E6;padding:6px 0">Close this file</span>
|
||||
<span style="display:flex;gap:4px"><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">⌘</span><span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A;border:1px solid #232C39;border-radius:2px;padding:5px 6px">.</span></span><span style="color:#E4E7E6;padding:6px 0">Hidden files on or off</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- note -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2c · project note ⌘n</div>
|
||||
<div data-screen-label="02c Note" style="width:1180px;height:620px;background:rgba(16,23,32,.72);border:1px solid #CDD1CD;border-radius:4px;padding:34px;overflow:hidden">
|
||||
<div style="height:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="height:50px;flex:none;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
|
||||
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A">▤</span>
|
||||
<span style="font:700 14px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Note</span>
|
||||
<span style="font:400 12px/1 'IBM Plex Mono',monospace;color:#6C7783">.notes.txt</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="display:flex;align-items:center;gap:8px;background:#E8913A;border-radius:2px;padding:6px 10px;font:500 12px/1 'IBM Plex Sans',sans-serif;color:#171C22">To the agent <span style="font:600 11px/1 'IBM Plex Mono',monospace">⌘P</span></span>
|
||||
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;padding:24px 28px;display:flex;flex-direction:column;gap:18px;font:400 14px/1.7 'IBM Plex Mono',monospace;color:#E4E7E6;max-width:68ch">
|
||||
<p style="margin:0">I've created a design for the admin screens that manage the vaults, vault_categories and the vault_subcategories.</p>
|
||||
<p style="margin:0">Implement them. Let them respect the admin policies just like agents, teams and companies have their own policies.</p>
|
||||
<p style="margin:0">The design can be found here: docs/design_handoff_vaults/README.md</p>
|
||||
<p style="margin:0">The vaults index page replaces /crud/vaults. Remove that code and place this feature on /admin/vaults.<span style="display:inline-block;width:8px;height:16px;background:#E8913A;vertical-align:-3px;margin-left:2px;animation:blink 1.1s steps(1,end) infinite"></span></p>
|
||||
</div>
|
||||
<div style="height:34px;flex:none;border-top:1px solid #232C39;display:flex;align-items:center;gap:12px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
|
||||
<span>saved on focus loss</span><span>·</span><span>412 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- launcher -->
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">2d · open a project</div>
|
||||
<div data-screen-label="02d Launcher" style="width:600px;height:620px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:8px;padding:0 12px">
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#E8913A"></span>
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#6C7783"></span>
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#3A424C"></span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;display:flex;align-items:flex-start;justify-content:center;padding:20px 40px 40px">
|
||||
<div style="width:100%;background:#18202B;border:1px solid #232C39;border-radius:8px;box-shadow:0 16px 48px rgba(23,28,34,.18);display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:16px 18px;border-bottom:1px solid #232C39;display:flex;gap:12px;align-items:center">
|
||||
<span style="font:700 18px/1 'IBM Plex Mono',monospace;color:#E8913A">◆</span>
|
||||
<span style="display:flex;flex-direction:column;gap:4px">
|
||||
<span style="font:700 15px/1 'IBM Plex Mono',monospace;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 Sans',sans-serif;color:#6C7783">Open a project to begin</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="height:46px;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid #232C39">
|
||||
<span style="font:400 14px/1 'IBM Plex Mono',monospace;color:#E8913A">+</span>
|
||||
<span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">Open new project…</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">↵</span>
|
||||
</div>
|
||||
<div style="padding:14px 18px 8px;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">recent</div>
|
||||
<div style="display:flex;flex-direction:column;padding-bottom:8px">
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">odynt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/odynt</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">stedt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/stedt</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 16px;background:#232C39;border-left:2px solid #E8913A"><span style="color:#E8913A">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">loopt</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#BAC0C0">~/code/loopt</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">doen-r</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/doen-r</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">vakwerk</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/vakwerk</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">diagram</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/diagram</span></div>
|
||||
<div style="height:38px;display:flex;align-items:center;gap:10px;padding:0 18px;border-left:2px solid transparent"><span style="color:#6C7783">▤</span><span style="font:500 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">marketingtools</span><span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">~/code/marketingtools</span></div>
|
||||
</div>
|
||||
<div style="height:36px;border-top:1px solid #232C39;display:flex;align-items:center;gap:12px;padding:0 18px;font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">
|
||||
<span><span style="color:#E8913A">⌘↑↓</span> navigate</span><span><span style="color:#E8913A">↵</span> open</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 03 · DIFF ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 03 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Diff</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Added is teal, removed is amber-deep. No green, no red.</span>
|
||||
</div>
|
||||
|
||||
<div data-screen-label="03 Diff" style="width:1760px;height:620px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:42px;flex:none;background:#18202B;border-bottom:1px solid #232C39;display:flex;align-items:center;gap:14px;padding:0 14px">
|
||||
<div style="display:flex;gap:7px;flex:none">
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#E8913A"></span>
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#6C7783"></span>
|
||||
<span style="width:11px;height:11px;border-radius:9999px;background:#3A424C"></span>
|
||||
</div>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/views/help/vault/declined-names.blade.php</span>
|
||||
<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">+1</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#C4741F">−1</span>
|
||||
<span style="font:400 11px/1 'IBM Plex Mono',monospace;color:#6C7783">staged → actual</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="display:flex;align-items:center;gap:8px;border:1px solid #232C39;border-radius:2px;padding:6px 10px;font:400 12px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Collapse <span style="font:600 10px/1 'IBM Plex Mono',monospace;color:#E8913A">esc</span></span>
|
||||
</div>
|
||||
<div style="flex:1;min-height:0;display:flex">
|
||||
<div style="flex:1;min-width:0;border-right:1px solid #232C39;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="height:30px;flex:none;display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span>original</span><span style="letter-spacing:0;text-transform:none;font-weight:400;color:#3A424C">before</span></div>
|
||||
<div style="display:grid;grid-template-columns:52px 1fr;align-content:start;font:400 13px/22px 'IBM Plex Mono',monospace;padding-top:8px">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A merge writes a declined row…') }<span>}</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:46.9px;color:#6C7783"></p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:31.3px;color:#6C7783"></section></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#C4741F;background:rgba(196,116,31,.14);border-left:2px solid #C4741F">22</span><span style="padding-left:31.3px;color:#E4E7E6;background:rgba(196,116,31,.14)"><span style="color:#6C7783"><</span>section <span style="color:#BAC0C0">class</span>=<span style="color:#8FBFB4">"odynt-help__section"</span><span style="color:#6C7783">></span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:46.9px;color:#BAC0C0"><h2>{<span>{</span> __('The reason a row can carry') }<span>}</span></h2></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span style="padding-left:46.9px;color:#6C7783"><p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A row can carry one of eleven reason codes…') }<span>}</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:46.9px;color:#6C7783"></p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span style="padding-left:31.3px;color:#6C7783"></section></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="height:30px;flex:none;display:flex;align-items:center;gap:10px;padding:0 14px;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783"><span style="color:#E8913A">updated</span><span style="letter-spacing:0;text-transform:none;font-weight:400;color:#3A424C">after</span></div>
|
||||
<div style="display:grid;grid-template-columns:52px 1fr;align-content:start;font:400 13px/22px 'IBM Plex Mono',monospace;padding-top:8px">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A merge writes a declined row…') }<span>}</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:46.9px;color:#6C7783"></p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:31.3px;color:#6C7783"></section></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#8FBFB4;background:rgba(143,191,180,.12);border-left:2px solid #8FBFB4">22</span><span style="padding-left:31.3px;color:#F4F5F4;background:rgba(143,191,180,.12)"><span style="color:#6C7783"><</span>section <span style="color:#BAC0C0">class</span>=<span style="color:#8FBFB4">"odynt-help__section is-reasons"</span><span style="color:#6C7783">></span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:46.9px;color:#BAC0C0"><h2>{<span>{</span> __('The reason a row can carry') }<span>}</span></h2></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span style="padding-left:46.9px;color:#6C7783"><p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px;color:#BAC0C0">{<span>{</span> __('A row can carry one of eleven reason codes…') }<span>}</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:46.9px;color:#6C7783"></p></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span style="padding-left:31.3px;color:#6C7783"></section></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 04 · DETAILS ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 04 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Details</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Menus, prompts, tooltips and the markdown switch. All on 24px rows.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4a · explorer menu</div>
|
||||
<div style="width:320px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:8px 0;display:flex;flex-direction:column">
|
||||
<div style="padding:6px 14px 10px;font:400 11px/1.4 'IBM Plex Mono',monospace;color:#6C7783;border-bottom:1px solid #232C39;margin-bottom:6px">tests/Unit/ExampleTest.php</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 12px;margin:0 2px;background:#232C39;border-left:2px solid #E8913A;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4"><span style="color:#E8913A">◇</span>Pass on reference</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">⧉</span>Copy reference</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">◇</span>Pass on file name</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">⧉</span>Copy file name</div>
|
||||
<div style="margin:6px 0;border-top:1px solid #232C39"></div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">✎</span>Rename file</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">▤</span>Show in Finder</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#C4741F"><span>⌫</span>Delete file</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4b · source control menu</div>
|
||||
<div style="width:320px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:8px 0;display:flex;flex-direction:column">
|
||||
<div style="padding:6px 14px 10px;font:400 11px/1.4 'IBM Plex Mono',monospace;color:#6C7783;border-bottom:1px solid #232C39;margin-bottom:6px">resources/views/help/vault/declined-names.blade.php</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 12px;margin:0 2px;background:#232C39;border-left:2px solid #E8913A;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4"><span style="color:#E8913A">◇</span>Pass on reference</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">⧉</span>Copy reference</div>
|
||||
<div style="margin:6px 0;border-top:1px solid #232C39"></div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#8FBFB4">+</span>Stage changes</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0"><span style="color:#6C7783">⇄</span>Open diff</div>
|
||||
<div style="height:30px;display:flex;align-items:center;gap:10px;padding:0 14px;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#C4741F"><span>↺</span>Discard changes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4c · pass on to agent</div>
|
||||
<div style="width:460px;background:#18202B;border:1px solid #232C39;border-radius:4px;box-shadow:0 4px 16px rgba(23,28,34,.10);padding:14px;display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="font:400 13px/1 'IBM Plex Mono',monospace;color:#E8913A">◇</span>
|
||||
<span style="font:700 13px/1 'IBM Plex Mono',monospace;color:#F4F5F4">Pass on to agent</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:400 10px/1 'IBM Plex Mono',monospace;color:#6C7783;border:1px solid #232C39;border-radius:2px;padding:4px 6px">esc</span>
|
||||
</div>
|
||||
<div style="height:38px;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:2px;padding:0 10px;font:400 13px/1 'IBM Plex Mono',monospace;color:#F4F5F4">
|
||||
<span>write the test for this</span>
|
||||
<span style="width:1px;height:16px;background:#E8913A;animation:blink 1.1s steps(1,end) infinite"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783;flex:none">inserts</span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Mono',monospace;color:#171C22;background:#E8913A;border-radius:2px;padding:5px 7px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">write the test for this ⇒ tests/Unit/ExampleTest.php</span>
|
||||
</div>
|
||||
<div style="font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783"><span style="color:#E8913A">↵</span> insert into agent · <span style="color:#E8913A">esc</span> cancel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4d · toolbar + tooltip</div>
|
||||
<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">
|
||||
<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">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:#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>
|
||||
</div>
|
||||
<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="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">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>
|
||||
</sc-if>
|
||||
<div style="padding:12px;border-top:1px solid #232C39;font:400 11px/1.5 'IBM Plex Mono',monospace;color:#6C7783">Tooltip: slate-3 surface, 1px border, no arrow, no shadow. The state is amber inside the sentence.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">4e · markdown actual · preview</div>
|
||||
<div style="width:760px;height:360px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex">
|
||||
<div style="flex:1;min-width:0;border-right:1px solid #232C39;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">markdown</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
|
||||
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A">Actual</span>
|
||||
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0;border-left:1px solid #232C39">Preview</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:44px 1fr;align-content:start;font:400 12px/20px 'IBM Plex Mono',monospace;padding-top:8px">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#BAC0C0">You write the handover record for a</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span style="color:#BAC0C0">conversation that is about to be cut.</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span style="color:#BAC0C0">The reader is a model with no access to</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span style="color:#BAC0C0">the original messages.</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span style="color:#F0B476">## Sources</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span style="color:#BAC0C0">Use only the messages, the tool results</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span style="color:#BAC0C0">and the previous summary.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:12px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">markdown</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
|
||||
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Actual</span>
|
||||
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A;border-left:1px solid #232C39">Preview</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="padding:18px 20px;display:flex;flex-direction:column;gap:14px;max-width:68ch">
|
||||
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6">You write the handover record for a conversation that is about to be cut.</p>
|
||||
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6">The reader is a model with no access to the original messages. Your record is the only input it gets.</p>
|
||||
<h3 style="margin:0;padding-bottom:8px;border-bottom:1px solid #232C39;font:700 16px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em;color:#F4F5F4">Sources</h3>
|
||||
<p style="margin:0;font:400 13px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">Use only the messages, the tool results and the previous summary. If you cannot point to that source, delete the line.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 05 · FOUNDATIONS ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 05 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Foundations</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">Four surfaces, one accent, six syntax colours.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
|
||||
|
||||
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:16px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">surfaces and accent</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#101720;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#101720</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">editor · terminal</span></span></div>
|
||||
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#18202B;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#18202B</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">panels · chrome</span></span></div>
|
||||
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#232C39;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#232C39</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">selection · border</span></span></div>
|
||||
<div style="display:flex;gap:12px;align-items:center"><span style="width:44px;height:44px;background:#E8913A;border:1px solid #CDD1CD;border-radius:2px"></span><span style="display:flex;flex-direction:column;gap:2px"><span style="font:600 12px/1.3 'IBM Plex Mono',monospace">#E8913A</span><span style="font:400 11px/1.3 'IBM Plex Sans',sans-serif;color:#6B7480">action · state · cursor</span></span></div>
|
||||
</div>
|
||||
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">Amber is a reservation: active toggle, selection marker, cursor, the blinking period and the counts in the status bar. Never an amber surface as decoration.</p>
|
||||
</div>
|
||||
|
||||
<div style="width:520px;background:#101720;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:16px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">syntax</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font:400 12px/1.4 'IBM Plex Mono',monospace">
|
||||
<span style="color:#E4E7E6">text · #E4E7E6</span>
|
||||
<span style="color:#BAC0C0">variable · #BAC0C0</span>
|
||||
<span style="color:#6C7783">comment · #6C7783</span>
|
||||
<span style="color:#C3A6CE">keyword · #C3A6CE</span>
|
||||
<span style="color:#8FBFB4">string · #8FBFB4</span>
|
||||
<span style="color:#F0B476">number · tag · #F0B476</span>
|
||||
</div>
|
||||
<div style="border-top:1px solid #232C39;padding-top:14px;display:flex;flex-direction:column;gap:8px;font:400 12px/1.6 'IBM Plex Mono',monospace">
|
||||
<span style="color:#8FBFB4">+ added — #8FBFB4 op rgba(143,191,180,.12)</span>
|
||||
<span style="color:#C4741F">− removed — #C4741F op rgba(196,116,31,.14)</span>
|
||||
</div>
|
||||
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#BAC0C0;text-wrap:pretty">Six colours, all muted. No green for good and no red for bad — the diff reads on shape and on the 2px rule, not on alarm.</p>
|
||||
</div>
|
||||
|
||||
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">type and rhythm</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<span style="font:700 24px/1.1 'IBM Plex Mono',monospace;letter-spacing:-.03em">IBM Plex Mono 700</span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#6B7480">titles, labels, paths, code, shortcuts</span>
|
||||
<span style="font:400 20px/1.3 'IBM Plex Sans',sans-serif">IBM Plex Sans 400 · 500</span>
|
||||
<span style="font:400 12px/1.4 'IBM Plex Sans',sans-serif;color:#6B7480">rows, menu items, tooltips, running text</span>
|
||||
</div>
|
||||
<div style="border-top:1px solid #CDD1CD;padding-top:14px;display:grid;grid-template-columns:1fr 1fr;gap:8px;font:400 12px/1.6 'IBM Plex Mono',monospace;color:#3A424C">
|
||||
<span>row height · 24px</span><span>chrome · 44px</span>
|
||||
<span>code line · 20px</span><span>status bar · 28px</span>
|
||||
<span>radius control · 2px</span><span>radius overlay · 8px</span>
|
||||
<span>border · 1px</span><span>marker · 2px amber</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width:520px;background:#E5E7E4;border:1px solid #CDD1CD;border-radius:4px;padding:24px;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">row states</div>
|
||||
<div style="background:#18202B;border:1px solid #232C39;border-radius:2px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">rest</div>
|
||||
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;background:#1E2733;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#E4E7E6">hover — fill one step deeper</div>
|
||||
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 10px;border-left:2px solid #E8913A;background:#232C39;font:500 13px/1 'IBM Plex Sans',sans-serif;color:#F4F5F4">selected — 2px amber on the left</div>
|
||||
<div style="height:28px;display:flex;align-items:center;gap:10px;padding:0 12px;border-left:2px solid transparent;font:400 13px/1 'IBM Plex Sans',sans-serif;color:#6C7783;opacity:.4">not available — 40%</div>
|
||||
</div>
|
||||
<p style="margin:0;font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#3A424C;text-wrap:pretty">No movement, no shadow, no scale. Colour only, 160ms. The only animation in the whole editor is the blinking cursor and the period in the mark.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== 06 · SYNTAX ===================== -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;align-items:baseline;gap:16px;border-bottom:1px solid #CDD1CD;padding-bottom:12px">
|
||||
<span style="font:700 11px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#C4741F">[ 06 ]</span>
|
||||
<span style="font:700 20px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em">Code colours</span>
|
||||
<span style="font:400 13px/1.6 'IBM Plex Sans',sans-serif;color:#6B7480">The same six colours across every language. Nothing bold, nothing italic — weight stays 400 so the column reads flat.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:32px;flex-wrap:wrap;align-items:flex-start">
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6a · javascript</div>
|
||||
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#F0B476;border-radius:2px;padding:3px 4px">js</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/js/symbols-service.js</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">javascript</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#6C7783">// One request per file. The tree keeps the last answer.</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#C3A6CE">import</span> { <span style="color:#BAC0C0">cache</span> } <span style="color:#C3A6CE">from</span> <span style="color:#8FBFB4">'./cache.js'</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">ENDPOINT</span> = <span style="color:#8FBFB4">'/api/symbols'</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">TIMEOUT</span> = <span style="color:#F0B476">2500</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span><span style="color:#C3A6CE">export async function</span> <span style="color:#E4E7E6">readSymbols</span>(<span style="color:#BAC0C0">path</span>, <span style="color:#BAC0C0">signal</span>) {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">if</span> (<span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">has</span>(<span style="color:#BAC0C0">path</span>)) <span style="color:#C3A6CE">return</span> <span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">get</span>(<span style="color:#BAC0C0">path</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">res</span> = <span style="color:#C3A6CE">await</span> <span style="color:#E4E7E6">fetch</span>(<span style="color:#8FBFB4">`${ENDPOINT}?p=${path}`</span>, { <span style="color:#BAC0C0">signal</span> });</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">if</span> (!<span style="color:#BAC0C0">res</span>.<span style="color:#BAC0C0">ok</span>) {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">throw new</span> <span style="color:#E4E7E6">Error</span>(<span style="color:#8FBFB4">`symbols: ${res.status}`</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span style="padding-left:15.6px">}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">const</span> <span style="color:#BAC0C0">rows</span> = <span style="color:#C3A6CE">await</span> <span style="color:#BAC0C0">res</span>.<span style="color:#E4E7E6">json</span>();</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">cache</span>.<span style="color:#E4E7E6">set</span>(<span style="color:#BAC0C0">path</span>, <span style="color:#BAC0C0">rows</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span style="padding-left:15.6px"><span style="color:#C3A6CE">return</span> <span style="color:#BAC0C0">rows</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span>}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6b · php</div>
|
||||
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#C3A6CE;border-radius:2px;padding:3px 4px">php</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">app/Console/Commands/ConfigSyncCommand.php</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">php</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span><span style="color:#6C7783"><?php</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#C3A6CE">declare</span>(<span style="color:#BAC0C0">strict_types</span>=<span style="color:#F0B476">1</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span><span style="color:#C3A6CE">namespace</span> <span style="color:#8FBFB4">App\Console\Commands</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span><span style="color:#C3A6CE">use</span> <span style="color:#8FBFB4">App\Models\Config</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span><span style="color:#6C7783">/** Writes one row per config group. */</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span><span style="color:#C3A6CE">final class</span> <span style="color:#E4E7E6">ConfigSyncCommand</span> <span style="color:#C3A6CE">extends</span> <span style="color:#E4E7E6">Command</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span>{</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">protected</span> <span style="color:#BAC0C0">$signature</span> = <span style="color:#8FBFB4">'config:sync'</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:31.3px"><span style="color:#C3A6CE">public function</span> <span style="color:#E4E7E6">handle</span>(): <span style="color:#C3A6CE">int</span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span style="padding-left:31.3px">{</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span style="padding-left:62.6px"><span style="color:#BAC0C0">$created</span> = <span style="color:#F0B476">0</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:62.6px"><span style="color:#C3A6CE">foreach</span> (<span style="color:#BAC0C0">$this</span>-><span style="color:#E4E7E6">registry</span>() <span style="color:#C3A6CE">as</span> <span style="color:#BAC0C0">$key</span> => <span style="color:#BAC0C0">$class</span>) {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span style="padding-left:93.8px"><span style="color:#BAC0C0">$row</span> = <span style="color:#E4E7E6">Config</span>::<span style="color:#E4E7E6">query</span>()-><span style="color:#E4E7E6">find</span>(<span style="color:#BAC0C0">$key</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span style="padding-left:93.8px"><span style="color:#C3A6CE">if</span> (<span style="color:#BAC0C0">$row</span> === <span style="color:#F0B476">null</span>) {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">22</span><span style="padding-left:125.1px"><span style="color:#BAC0C0">$created</span>++;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:93.8px">}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span style="padding-left:62.6px">}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">25</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">26</span><span style="padding-left:62.6px"><span style="color:#C3A6CE">return</span> <span style="color:#E4E7E6">self</span>::<span style="color:#BAC0C0">SUCCESS</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">27</span><span style="padding-left:31.3px">}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">28</span><span>}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6c · css</div>
|
||||
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#8FBFB4;border-radius:2px;padding:3px 4px">css</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">resources/css/panels.css</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">css</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:48px 1fr;align-content:start;font:400 13px/20px 'IBM Plex Mono',monospace;padding:10px 0">
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">1</span><span style="color:#6C7783">/* Four surfaces, one accent. Nothing else. */</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">2</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">3</span><span><span style="color:#E4E7E6">:root</span> {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">4</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--editor</span>: <span style="color:#F0B476">#101720</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">5</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--panel</span>: <span style="color:#F0B476">#18202B</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">6</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">--accent</span>: <span style="color:#F0B476">#E8913A</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">7</span><span>}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">8</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">9</span><span><span style="color:#E4E7E6">.row</span> {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">10</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">display</span>: <span style="color:#8FBFB4">flex</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">11</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">gap</span>: <span style="color:#F0B476">8</span>px;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">12</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">height</span>: <span style="color:#F0B476">24</span>px;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">13</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">border-left</span>: <span style="color:#F0B476">2</span>px <span style="color:#8FBFB4">solid</span> <span style="color:#8FBFB4">transparent</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">14</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">transition</span>: <span style="color:#BAC0C0">background</span> <span style="color:#F0B476">160</span>ms <span style="color:#8FBFB4">ease-out</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">15</span><span>}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">16</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">17</span><span><span style="color:#E4E7E6">.row</span><span style="color:#C3A6CE">:hover</span> {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">18</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">#1E2733</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">19</span><span>}</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">20</span><span></span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">21</span><span><span style="color:#E4E7E6">.row</span><span style="color:#C3A6CE">[aria-selected='true']</span> {</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">22</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">background</span>: <span style="color:#F0B476">#232C39</span>;</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">23</span><span style="padding-left:15.6px"><span style="color:#BAC0C0">border-left-color</span>: <span style="color:#F0B476">var</span>(<span style="color:#8FBFB4">--accent</span>);</span>
|
||||
<span style="text-align:right;padding-right:12px;color:#3A424C">24</span><span>}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6B7480">6d · markdown preview</div>
|
||||
<div style="width:620px;background:#101720;border:1px solid #232C39;border-radius:4px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="height:34px;flex:none;display:flex;align-items:center;gap:10px;padding:0 12px;border-bottom:1px solid #232C39">
|
||||
<span style="font:600 8px/1 'IBM Plex Mono',monospace;color:#101720;background:#BAC0C0;border-radius:2px;padding:3px 4px">md</span>
|
||||
<span style="font:500 12px/1 'IBM Plex Mono',monospace;color:#F4F5F4">blueprints/046-symbols/blueprint.md</span>
|
||||
<span style="flex:1"></span>
|
||||
<span style="display:flex;border:1px solid #232C39;border-radius:2px;overflow:hidden">
|
||||
<span style="padding:5px 10px;font:400 11px/1 'IBM Plex Sans',sans-serif;color:#BAC0C0">Actual</span>
|
||||
<span style="padding:5px 10px;font:500 11px/1 'IBM Plex Sans',sans-serif;color:#171C22;background:#E8913A;border-left:1px solid #232C39">Preview</span>
|
||||
</span>
|
||||
</div>
|
||||
<div style="padding:26px 30px 30px;display:flex;flex-direction:column;gap:18px;max-width:68ch">
|
||||
<span style="font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">blueprint · 046</span>
|
||||
<h2 style="margin:0;font:700 24px/1.1 'IBM Plex Mono',monospace;letter-spacing:-.03em;color:#F4F5F4">Symbols in the tree</h2>
|
||||
<p style="margin:0;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#E4E7E6;text-wrap:pretty">The explorer shows classes and functions under a file once the file is open. The list comes from one request per file and it is cached for the session.</p>
|
||||
<h3 style="margin:0;padding-bottom:8px;border-bottom:1px solid #232C39;font:700 17px/1.2 'IBM Plex Mono',monospace;letter-spacing:-.02em;color:#F4F5F4">What it changes</h3>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">
|
||||
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none">—</span><span>A row gains one child level. Nothing else in the tree moves.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none">—</span><span>The endpoint answers in under <span style="font:400 13px/1.5 'IBM Plex Mono',monospace;color:#F0B476;background:#18202B;border:1px solid #232C39;border-radius:2px;padding:1px 5px">250ms</span> for a file of 2000 lines.</span></div>
|
||||
<div style="display:flex;gap:10px"><span style="color:#E8913A;flex:none">—</span><span>A parse error leaves the file row untouched and logs once.</span></div>
|
||||
</div>
|
||||
<div style="border-left:2px solid #E8913A;padding:2px 0 2px 16px;font:italic 400 15px/1.6 'IBM Plex Sans',sans-serif;color:#F0B476">A symbol list nobody asked for is a symbol list nobody reads. Open the file first.</div>
|
||||
<div style="background:#18202B;border:1px solid #232C39;border-radius:2px;padding:14px 16px;display:grid;grid-template-columns:1fr;gap:0;font:400 13px/20px 'IBM Plex Mono',monospace">
|
||||
<span style="color:#6C7783">// the shape one row returns</span>
|
||||
<span style="color:#E4E7E6">{ <span style="color:#BAC0C0">rel</span>: <span style="color:#8FBFB4">'class'</span>, <span style="color:#BAC0C0">line</span>: <span style="color:#F0B476">42</span>, <span style="color:#BAC0C0">kind</span>: <span style="color:#8FBFB4">'php'</span> }</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:0;border:1px solid #232C39;border-radius:2px;overflow:hidden;font:400 13px/1 'IBM Plex Sans',sans-serif">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 90px;background:#18202B;border-bottom:1px solid #232C39;font:700 10px/1 'IBM Plex Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:#6C7783">
|
||||
<span style="padding:10px 14px">step</span><span style="padding:10px 14px">file</span><span style="padding:10px 14px">state</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 90px;color:#BAC0C0;border-bottom:1px solid #232C39">
|
||||
<span style="padding:10px 14px">Endpoint</span><span style="padding:10px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px">routes/api.php</span><span style="padding:10px 14px;color:#8FBFB4">done</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 90px;color:#BAC0C0">
|
||||
<span style="padding:10px 14px">Tree level</span><span style="padding:10px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px">explorer.js</span><span style="padding:10px 14px;color:#E8913A">open</span>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin:0;font:400 14px/1.7 'IBM Plex Sans',sans-serif;color:#BAC0C0">Full contract in <a href="#" style="color:#E8913A;border-bottom:1px solid #232C39">docs/symbols.md</a> →</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"showTooltips":{"editor":"boolean","default":true,"tsType":"boolean","section":"Details"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
return { showTooltips: this.props.showTooltips ?? true };
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
386
docs/design/README.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# Handoff: Helder — editor for working with Claude Code
|
||||
|
||||
## Overview
|
||||
|
||||
Helder is a desktop code editor built around one job: work on a codebase together with
|
||||
Claude Code. It replaces a general IDE with four columns — source control, explorer,
|
||||
editor, agent — plus five overlays (search, keyboard shortcuts, project note, project
|
||||
launcher, expanded diff) and a set of small surfaces (context menus, a pass-on-to-agent
|
||||
prompt, tooltips, a markdown preview).
|
||||
|
||||
This handoff describes one consistent visual system for all of those screens.
|
||||
|
||||
## About the design files
|
||||
|
||||
The files in this bundle are **design references written in HTML**. They show the
|
||||
intended look, spacing and states. They are not production code to copy.
|
||||
|
||||
The task is to **recreate these screens in the target codebase's own environment**
|
||||
(Electron + React, Tauri + Svelte, SwiftUI, whatever the app already uses) with its
|
||||
established patterns. If no environment exists yet, pick the framework that fits a
|
||||
desktop editor and implement the design there.
|
||||
|
||||
`Helder IDE.dc.html` is a single-canvas board: every screen sits on one page, side by
|
||||
side, at real pixel sizes. Open it in a browser to inspect any value with devtools.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**High fidelity.** Colours, type, row heights, paddings, radii and states are final and
|
||||
exact. Recreate them pixel-for-pixel. Everything is authored with inline styles so any
|
||||
element's computed value can be read directly from the file.
|
||||
|
||||
Two things are deliberately *not* final:
|
||||
- The code shown inside the editor panes is sample content, not a spec.
|
||||
- Icons are Unicode stand-ins (`⌕ ▤ ◇ ⧉ ✎ ↺ ⇄ ⑂ ◆ →`). Ship them as Lucide glyphs at
|
||||
stroke width 1.75, 16px inline / 18px standalone.
|
||||
|
||||
---
|
||||
|
||||
## Design tokens
|
||||
|
||||
### Surfaces (four, no more)
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `editor` | `#101720` | Editor canvas, terminal, launcher backdrop, code blocks |
|
||||
| `panel` | `#18202B` | Title bar, side panels, status bar, overlays, menus, tooltip host |
|
||||
| `raised` | `#232C39` | Selected row, hairline borders, tooltip surface, keycap borders |
|
||||
| `hover` | `#1E2733` | Row hover fill only |
|
||||
|
||||
### Text on dark
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `text-1` | `#F4F5F4` | Active file, dialog titles, selected row |
|
||||
| `text-2` | `#E4E7E6` | Body copy, code plain text |
|
||||
| `text-3` | `#BAC0C0` | Inactive rows, identifiers, secondary copy |
|
||||
| `text-4` | `#6C7783` | Labels, paths, meta, comments |
|
||||
| `text-5` | `#3A424C` | Line numbers, disabled |
|
||||
|
||||
### Accent — amber, reserved
|
||||
|
||||
`#E8913A` primary · `#C4741F` deep · `#F0B476` soft · `rgba(232,145,58,.10)` tint ·
|
||||
`rgba(232,145,58,.22)` focus ring.
|
||||
|
||||
Amber is allowed **only** for: the active toolbar toggle (tint fill), the 2px selection
|
||||
rule, the caret, keycap glyphs, the blinking period in the wordmark, the primary button
|
||||
fill (ink text on amber, never white), the search-hit highlight, section markers, list
|
||||
markers, links, and counts in the status bar. Never an amber panel, never an amber
|
||||
heading.
|
||||
|
||||
### Light canvas (this board only)
|
||||
|
||||
`#EFF0EE` page · `#E5E7E4` spec card · `#CDD1CD` hairline · `#171C22` ink ·
|
||||
`#3A424C` ink muted · `#6B7480` ink dim. The application itself is dark only.
|
||||
|
||||
### Syntax — six colours, all muted
|
||||
|
||||
| Role | Hex |
|
||||
|---|---|
|
||||
| plain / function name | `#E4E7E6` |
|
||||
| variable / property | `#BAC0C0` |
|
||||
| comment | `#6C7783` |
|
||||
| keyword / operator / pseudo-class | `#C3A6CE` |
|
||||
| string / value / type path | `#8FBFB4` |
|
||||
| number / tag / hex literal | `#F0B476` |
|
||||
|
||||
No bold, no italic in code. Weight stays 400 so the column reads flat.
|
||||
|
||||
### Diff
|
||||
|
||||
- added: text `#8FBFB4`, fill `rgba(143,191,180,.12)`, 2px left rule `#8FBFB4`
|
||||
- removed: text `#C4741F`, fill `rgba(196,116,31,.14)`, 2px left rule `#C4741F`
|
||||
- counts in headers: `+1` in `#8FBFB4`, `−1` in `#C4741F` (real minus sign U+2212)
|
||||
|
||||
No green, no red anywhere. The diff reads on shape and on the 2px rule.
|
||||
|
||||
### Type
|
||||
|
||||
- `IBM Plex Mono` — 400/500/600/700. All labels, paths, code, keycaps, titles, numbers.
|
||||
- `IBM Plex Sans` — 400/500. Rows, menu items, tooltips, buttons, running text.
|
||||
|
||||
| Role | Spec |
|
||||
|---|---|
|
||||
| Board display | Mono 700, 58px/1.02, `-0.04em` |
|
||||
| Section title | Mono 700, 20px/1.2, `-0.02em` |
|
||||
| Dialog title | Mono 700, 14px/1 |
|
||||
| Section/panel label | Mono 700, 10px, `0.14em`, uppercase, `#6C7783` |
|
||||
| Board label | Mono 700, 10–11px, `0.14em`, uppercase, `#6B7480` |
|
||||
| Code | Mono 400, 13px / 20px line box |
|
||||
| Search hit line | Mono 400, 12px / 24px line box |
|
||||
| Path / meta / status bar | Mono 400, 11–12px |
|
||||
| Keycap | Mono 600, 12px, padding 4px 6px, 1px `#232C39`, radius 2px, glyph `#E8913A` |
|
||||
| Row label | Sans 400 13px (500 when selected) |
|
||||
| Body copy | Sans 400, 14px/1.7, max 68ch |
|
||||
| Tooltip | Sans 400, 12px/1.5 |
|
||||
|
||||
### Geometry
|
||||
|
||||
| Value | Where |
|
||||
|---|---|
|
||||
| 24px | Explorer / launcher-adjacent row height |
|
||||
| 26px | Source-control row height |
|
||||
| 28px | Status bar, state-specimen rows |
|
||||
| 30px | Context-menu item height |
|
||||
| 34px | Editor tab strip, markdown/code panel header |
|
||||
| 44px | Title bar and toolbar |
|
||||
| 20px | Code line box (13px type) |
|
||||
| 2px | Radius: buttons, inputs, tags, keycaps, menus' inner rows, code blocks |
|
||||
| 4px | Radius: panels, cards, menus |
|
||||
| 8px | Radius: overlays (modals) only |
|
||||
| 1px | Every hairline (`#232C39` on dark, `#CDD1CD` on light) |
|
||||
| 2px | Accent rule: selection, focus border, pull-quote |
|
||||
| 9999px | Traffic-light dots and avatars only |
|
||||
|
||||
Column widths in the workspace: source control 236 · explorer 288 · editor flex ·
|
||||
agent 480. Agent terminal block 190px tall. Full frame 1760 × 1000.
|
||||
|
||||
### Elevation
|
||||
|
||||
Only two shadows exist:
|
||||
- dropdown / context menu: `0 4px 16px rgba(23,28,34,.10)`
|
||||
- overlay: `0 16px 48px rgba(23,28,34,.18)`
|
||||
|
||||
No ambient card shadow, no glow.
|
||||
|
||||
### Motion
|
||||
|
||||
Colour transitions only, 160ms `cubic-bezier(0.22,1,0.36,1)` (120ms on small controls).
|
||||
The single exception is `blink`: a stepped 1.1s on/off (no fade) used for the caret, the
|
||||
terminal block cursor and the amber period in the wordmark. No entrance animation, no
|
||||
lift, no scale, no parallax.
|
||||
|
||||
---
|
||||
|
||||
## Screens
|
||||
|
||||
### 01 · Workspace (1760 × 1000)
|
||||
|
||||
Vertical stack: title bar 44px → body (flex row) → status bar 28px.
|
||||
|
||||
**Title bar** (`#18202B`, 1px bottom `#232C39`, padding 0 14px, gap 18px)
|
||||
Traffic lights 12px (`#E8913A`, `#6C7783`, `#3A424C`, gap 8px) · wordmark
|
||||
`helder` Mono 700 13px `#F4F5F4` with a blinking `.` in `#E8913A` · project `odynt`
|
||||
Mono 12px `#6C7783` · `·` · branch `main` Mono 500 12px `#E8913A` · breadcrumb of the
|
||||
open path in Mono 12px `#6C7783`, last segment `#F4F5F4` 600, `›` separators · then the
|
||||
toolbar, right-aligned, gap 4px.
|
||||
|
||||
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)`.
|
||||
Hidden-files-off is the muted case: label and keycap both `#6C7783`. Toggles:
|
||||
Search ⌘F · Fluid ⌘L · Hidden ⌘. · Note ⌘N · Git ⌘G · `?` (26px square, 1px border).
|
||||
|
||||
**Source control column** (236px, `#18202B`, 1px right border)
|
||||
Commit box: 52px `#101720` field, 1px `#232C39`, radius 2, placeholder
|
||||
"Shift+Enter to commit" `#6C7783`; 34px push button `↑` beside it, same border.
|
||||
Group header: Mono 700 10px `0.14em` uppercase `#6C7783`, count right in `#E8913A`
|
||||
("STAGED CHANGES 4", "CHANGES 1"). Rows 26px: status letter (Mono 600 10px — staged
|
||||
`#8FBFB4`, unstaged `#F0B476`) · 8px type badge · file name Sans 12px, ellipsised.
|
||||
Footer line "4 staged · 1 unstaged" Mono 11px `#6C7783`.
|
||||
|
||||
**Explorer** (288px, `#18202B`) — 30px header ("EXPLORER" / project name), then 24px
|
||||
rows. Indent 16px per level (12 · 28 · 44 · 60 · 76 padding-left). Collapsed `›`
|
||||
`#6C7783`; expanded `⌄` `#E8913A`, its label `#F4F5F4`. File rows carry an 8px type
|
||||
badge and, when dirty, a trailing `M` in `#8FBFB4`.
|
||||
|
||||
Type badge: Mono 600 8px, ink text `#101720` on a per-extension fill, radius 2,
|
||||
padding 3px 4px — php `#C3A6CE` · md `#BAC0C0` · js `#F0B476` · css `#8FBFB4`.
|
||||
|
||||
**Editor** (flex, `#101720`) — 34px strip: "MODIFIED" label, `+1` / `−1` counts, then a
|
||||
right-aligned segmented control (1px `#232C39`, radius 2, dividers 1px): Actual ·
|
||||
Original · Diff · Split. Active segment = amber fill with `#171C22` text.
|
||||
Code area: CSS grid `56px 1fr`, line box 20px, numbers right-aligned `#3A424C` with
|
||||
14px right padding. **Indentation is padding-left on the code cell** (7.82px per space:
|
||||
31.3 / 62.6 / 93.8 / 125.1) — do not ship non-breaking spaces.
|
||||
|
||||
**Agent panel** (480px, `#18202B`, 1px left border)
|
||||
Header: 60px `#232C39` square (mark), then `Claude Code` Mono 700 14px + version in
|
||||
`#6C7783`, model line Sans 12px `#BAC0C0`, cwd Mono 12px `#6C7783`.
|
||||
Notice strip: fill `rgba(232,145,58,.10)`, `[ ! ]` Mono 600 12px `#E8913A`, sentence
|
||||
Sans 12px, inline command in Mono `#F0B476`.
|
||||
Transcript: user turn prefixed `›` in `#E8913A`, agent turn `◆` in `#6C7783`, text
|
||||
Sans 13px/1.6. Tool calls sit in a `#101720` block, 1px `#232C39`: verb in `#8FBFB4`
|
||||
(read) or `#F0B476` (write), target and counts `#6C7783`.
|
||||
Prompt field: 44px, `#101720`, **2px `#E8913A` border + `0 0 0 2px rgba(232,145,58,.22)`
|
||||
ring** (this is the focus treatment everywhere), `›` prefix, blinking 1px caret.
|
||||
Status line: Mono 11px `#6C7783`; project chip = amber fill with `#171C22` text;
|
||||
`▸▸ auto mode` in `#E8913A`; slash hint right.
|
||||
Terminal: 190px, `#101720`, Mono 12px/1.7 — `[main]` `#8FBFB4`, `[~/path]$` `#6C7783`,
|
||||
output `#6C7783`, 7×14px blinking amber block cursor.
|
||||
|
||||
**Status bar** (28px, `#18202B`) — `⑂ main` in `#E8913A`, `+16` `#8FBFB4`, `−12`
|
||||
`#C4741F`, then right-aligned path · Ln/Col · UTF-8 · LF · language (`#BAC0C0`).
|
||||
|
||||
### 02a · Search (⌘F)
|
||||
|
||||
Overlay: flat ink scrim `rgba(16,23,32,.72)` (no blur), 34px inset, panel `#18202B`,
|
||||
1px `#232C39`, radius 8, overlay shadow.
|
||||
|
||||
- Query bar 54px: `⌕` `#E8913A`, query Mono 500 16px `#F4F5F4`, blinking caret; right
|
||||
side = extension filters (active = amber fill + ink text; inactive = 1px border,
|
||||
`#BAC0C0`) and a Mono 11px result summary `#6C7783` behind a 1px left divider.
|
||||
- Three columns: **this file** (230px) · **project** (flex) · **files** (250px), split by
|
||||
1px borders. Column headers are the standard 10px mono labels; the focused column's
|
||||
header is `#E8913A` with a count chip (amber fill, ink text).
|
||||
- Result group header: type badge + path Mono 12px `#BAC0C0`, hit count right `#6C7783`.
|
||||
- Hit lines: grid `64px 1fr`, line box 24px. Match run = amber fill with `#171C22` text
|
||||
on the selected row, `#F0B476` text elsewhere. Selected row: `#232C39` fill, 2px amber
|
||||
left rule, and the line number lifts to `#E8913A` (never leave it at `#3A424C`).
|
||||
- Empty column state: one plain sentence, Sans 12px `#6C7783` ("No matches in this
|
||||
file." / "No file names match."). No illustration.
|
||||
- Footer 38px: key hints (`⌘←`/`⌘→` column, `⌘↑`/`⌘↓` row, `↵` open) with amber glyphs,
|
||||
`esc` chip right.
|
||||
|
||||
### 02b · Keyboard shortcuts (`?`)
|
||||
|
||||
Same overlay shell. 50px header with `◇` `#E8913A` + "Keyboard shortcuts" + `esc` chip.
|
||||
Body groups (`NAVIGATE`, `AGENT`, `GIT`, `FILE`) each a 10px mono label, then a
|
||||
`92px 1fr` grid: keycap cluster left (gap 4px), description Sans 13px `#E4E7E6` right,
|
||||
6px vertical padding per row.
|
||||
|
||||
### 02c · Project note (⌘N)
|
||||
|
||||
Same shell. Header: `▤` amber, "Note", filename `.notes.txt` Mono 12px `#6C7783`, then
|
||||
the primary button — amber fill, `#171C22` text, Sans 500 12px, radius 2, padding
|
||||
6px 10px, with a `⌘P` keycap at Mono 600 11px — then the `esc` chip.
|
||||
Body: Mono 400 14px/1.7 `#E4E7E6`, max 68ch, 18px between paragraphs, blinking caret at
|
||||
the end of the text. Footer 34px: "saved on focus loss · 412 characters".
|
||||
|
||||
The note is plain text and it is the agent's input: ⌘P sends the whole note.
|
||||
|
||||
### 02d · Project launcher
|
||||
|
||||
Window `#101720` with only traffic lights (34px), no title bar. Centred panel on
|
||||
`#18202B`, radius 8, overlay shadow, top-aligned with 20px/40px padding.
|
||||
Header: `◆` amber + wordmark with blinking period + "Open a project to begin" Sans 12px
|
||||
`#6C7783`. Then a 46px "Open new project…" row with `+` amber and an `↵` chip.
|
||||
`RECENT` label, then 38px rows: `▤` icon, name Sans 500 13px, path Mono 11px `#6C7783`.
|
||||
Selected row: `#232C39`, 2px amber left rule, icon and path lift one step.
|
||||
Footer 36px: `⌘↑↓ navigate · ↵ open`.
|
||||
|
||||
### 03 · Expanded diff (1760 × 620)
|
||||
|
||||
Own window: 42px title bar (traffic lights, path Mono 500 12px `#F4F5F4`, "MODIFIED",
|
||||
`+1` / `−1`, "staged → actual" Mono 11px `#6C7783`, and a right-aligned
|
||||
"Collapse `esc`" button with 1px border).
|
||||
Two panes split by a 1px border; each has a 30px header — left `ORIGINAL` + "before"
|
||||
`#3A424C`, right `UPDATED` in `#E8913A` + "after".
|
||||
Panes are grids `52px 1fr`, line box 22px, indentation as padding-left, and **identical
|
||||
line numbering in both panes** so the eye can travel across. Changed row gets the fill,
|
||||
the 2px left rule and a lifted number colour.
|
||||
|
||||
### 04 · Small surfaces
|
||||
|
||||
**Context menu** (explorer and source control) — 320px, `#18202B`, 1px `#232C39`,
|
||||
radius 4, dropdown shadow, 8px vertical padding. First a path header (Mono 11px
|
||||
`#6C7783`, 1px bottom border). Items 30px, Sans 13px `#BAC0C0`, icon `#6C7783`.
|
||||
Highlighted item: `#232C39` fill, 2px amber left rule, label `#F4F5F4` 500, icon amber.
|
||||
Destructive item ("Delete file", "Discard changes"): `#C4741F` label. Groups separated
|
||||
by a 1px rule with 6px margins.
|
||||
|
||||
Explorer items: Pass on reference · Copy reference · Pass on file name · Copy file name
|
||||
| Rename file · Show in Finder · Delete file.
|
||||
Source-control items: Pass on reference · Copy reference | Stage changes · Open diff ·
|
||||
Discard changes.
|
||||
|
||||
**Pass on to agent** — 460px panel, 14px padding, gap 12. Title row (`◇` amber +
|
||||
Mono 700 13px + `esc` chip), a focused 38px input (2px amber border + ring, blinking
|
||||
caret), then an INSERTS preview: 10px mono label + the composed string on an amber fill
|
||||
with `#171C22` text, radius 2, ellipsised. Footer hint `↵ insert into agent · esc cancel`
|
||||
with amber glyphs.
|
||||
|
||||
**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
|
||||
sentence in amber ("Hidden files: *hidden* — …", "Column widths: *fluid* — …"), then one
|
||||
sentence saying what a click does.
|
||||
|
||||
**Markdown Actual / Preview** — same 34px header on both halves; the segmented control's
|
||||
active side is the amber fill. Actual = the code grid (`44px 1fr`, line box 20px) with
|
||||
`##` headings in `#F0B476`. Preview: 18px/20px padding, max 68ch, Sans 13px/1.7 body,
|
||||
Mono 700 headings with a 1px `#232C39` underline.
|
||||
|
||||
### 06 · Code colours and markdown preview
|
||||
|
||||
Four specimens at 620px: a JS module, a PHP class, a CSS file and the full markdown
|
||||
preview. Each panel: 34px header with type badge, path Mono 500 12px, and a right
|
||||
language label (or the Actual/Preview control).
|
||||
|
||||
The markdown preview is the reading surface, so it is the one place where Sans body copy
|
||||
gets room:
|
||||
- eyebrow: 10px mono label `#6C7783`
|
||||
- h2: Mono 700 24px/1.1, `-0.03em`, `#F4F5F4`
|
||||
- h3: Mono 700 17px/1.2 with a 1px `#232C39` underline, 8px below the text
|
||||
- paragraph: Sans 14px/1.7 `#E4E7E6`, max 68ch, `text-wrap: pretty`
|
||||
- list: flex rows, gap 10, `—` marker in `#E8913A` (never a bullet, never an emoji)
|
||||
- inline code: Mono 13px `#F0B476` on `#18202B`, 1px `#232C39`, radius 2, padding 1px 5px
|
||||
- block quote: 2px amber left rule, 16px left padding, Sans *italic* 15px `#F0B476`
|
||||
- code block: `#18202B`, 1px `#232C39`, radius 2, padding 14px 16px, Mono 13px/20px,
|
||||
syntax palette as above
|
||||
- table: 1px `#232C39` container, radius 2; header row on `#18202B` with 10px mono
|
||||
labels; body rows Sans 13px `#BAC0C0`, cells 10px 14px, 1px row rules; state words use
|
||||
`#8FBFB4` (done) and `#E8913A` (open)
|
||||
- link: `#E8913A` with a 1px `#232C39` bottom border; hover lifts the text to `#F4F5F4`
|
||||
and the border to `#E8913A`. `→` marks forward action.
|
||||
|
||||
---
|
||||
|
||||
## Interactions and behaviour
|
||||
|
||||
| Trigger | Result |
|
||||
|---|---|
|
||||
| `⌘F` | Search overlay, query seeded from the editor selection |
|
||||
| `⌘↑` / `⌘↓` | Move the row cursor in the focused list |
|
||||
| `⌘←` / `⌘→` | Move focus between search columns |
|
||||
| `↵` | Open the selected row (search hit, file, recent project) |
|
||||
| `⌘→` (editor) | Pass the current selection to the agent |
|
||||
| `⌘M` | Cycle Actual · Original · Diff · Split |
|
||||
| `⌘C` / `⌘↵` / `⌘P` (git) | Focus commit message / commit staged / push branch |
|
||||
| `⌘A` | Toggle auto-fit panels (columns re-fit on resize and focus) |
|
||||
| `⌘.` | Toggle hidden dotfiles in tree and search |
|
||||
| `⌘G` | Show or hide the source-control column |
|
||||
| `⌘S` / `⌘W` / `⌘D` | Save / close / delete (confirm) the current file |
|
||||
| `⌘N` | Open the project note; it saves on focus loss |
|
||||
| `⌘P` (note open) | Send the whole note to the agent |
|
||||
| right-click a row | Context menu, anchored to the pointer, first item pre-highlighted |
|
||||
| `esc` | Close the top overlay, menu or prompt; never more than one |
|
||||
|
||||
Rules that hold everywhere:
|
||||
- Exactly one overlay at a time, on a flat ink scrim. Overlays never stack.
|
||||
- Hover changes fill one step and nothing else. No lift, no scale, no shadow, no opacity.
|
||||
- Press holds the hover colour. Nothing shrinks.
|
||||
- Focus is always the 2px amber border plus the 2px 22% ring. Never remove it.
|
||||
- Disabled is 40% opacity plus `cursor: not-allowed`, no recolouring.
|
||||
- Empty and error states are one plain sentence starting with an em dash. No icon, no
|
||||
apology, no exclamation mark.
|
||||
|
||||
## State
|
||||
|
||||
- `project` — path, branch, recent list (persisted)
|
||||
- `openFile` — path, language, dirty flag, cursor line/col, view mode
|
||||
(`actual|original|diff|split`)
|
||||
- `tree` — expanded paths, selected path, `showHidden`
|
||||
- `git` — staged[], unstaged[], commit message draft, ahead/behind counts
|
||||
- `agent` — model, session, transcript, prompt draft, pending tool calls, auth notices
|
||||
- `overlay` — `null | search | shortcuts | note | diff | launcher | menu | passOn`
|
||||
- `panels` — column widths, `autoFit`, `gitVisible`
|
||||
- `note` — text, dirty flag (write on blur)
|
||||
|
||||
Search runs per keystroke against three indexes (open file, project contents, file
|
||||
names) and reports counts per column; a query with no hits leaves the columns in their
|
||||
empty state rather than collapsing them.
|
||||
|
||||
## Assets
|
||||
|
||||
None. No images, no illustrations, no photography. Icons come from Lucide
|
||||
(`lucide@0.544.0`, stroke width 1.75); the Unicode glyphs in the mock are placeholders.
|
||||
Fonts are IBM Plex Mono and IBM Plex Sans (currently from Google Fonts — swap in local
|
||||
woff2 for a desktop build).
|
||||
|
||||
## Files
|
||||
|
||||
- `Helder IDE.dc.html` — the design board: all screens, one canvas, real sizes
|
||||
- `tokens/*.css` — the Kade token files the board is built on (colour, type, spacing,
|
||||
radius, elevation, motion, fonts)
|
||||
- `styles.css` — the token entry point
|
||||
|
||||
Open `Helder IDE.dc.html` in a browser; the board is inert (no interaction) by design.
|
||||
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.16.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.21.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.26.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.30.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.36.png
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.41.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.45.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
docs/design/screenshots/Screenshot 2026-09-03 at 09.34.49.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
8
docs/design/styles.css
Normal file
@@ -0,0 +1,8 @@
|
||||
@import url("tokens/fonts.css");
|
||||
@import url("tokens/colors.css");
|
||||
@import url("tokens/typography.css");
|
||||
@import url("tokens/spacing.css");
|
||||
@import url("tokens/radius.css");
|
||||
@import url("tokens/elevation.css");
|
||||
@import url("tokens/motion.css");
|
||||
@import url("tokens/base.css");
|
||||
23
docs/design/tokens/base.css
Normal file
@@ -0,0 +1,23 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-stone);
|
||||
color: var(--color-ink-body);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-body);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--color-amber-deep); text-decoration-color: var(--color-stone-rule); text-underline-offset: 3px; transition: var(--transition-color); }
|
||||
a:hover { color: var(--color-ink-body); text-decoration-color: var(--color-ink-dim); }
|
||||
[data-surface="slate"] a { color: var(--color-amber); }
|
||||
[data-surface="slate"] a:hover { color: var(--color-neutral-1); }
|
||||
|
||||
::selection { background: var(--color-amber-ring); color: var(--color-ink-body); }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--color-amber); outline-offset: 2px; }
|
||||
66
docs/design/tokens/colors.css
Normal file
@@ -0,0 +1,66 @@
|
||||
/* Kade palette — cool stone paper, slate-ink dark punctuation, amber as the only accent. */
|
||||
:root {
|
||||
/* Stone — the default canvas. Cool, quarried, never white. */
|
||||
--color-stone: #EFF0EE;
|
||||
--color-stone-2: #E5E7E4;
|
||||
--color-stone-3: #D8DBD7;
|
||||
--color-stone-rule: #CDD1CD;
|
||||
|
||||
/* Ink ramp — text on stone. */
|
||||
--color-ink-body: #171C22;
|
||||
--color-ink-muted: #3A424C;
|
||||
--color-ink-dim: #6B7480;
|
||||
|
||||
/* Slate — the sparing dark surface. One or two blocks per page. */
|
||||
--color-slate: #101720;
|
||||
--color-slate-2: #18202B;
|
||||
--color-slate-3: #232C39;
|
||||
|
||||
/* Neutral ramp — text on slate. Pure white does not exist in this system. */
|
||||
--color-neutral-1: #F4F5F4;
|
||||
--color-neutral-2: #E4E7E6;
|
||||
--color-neutral-3: #BAC0C0;
|
||||
--color-neutral-4: #6C7783;
|
||||
|
||||
/* Amber — the single accent. Reserved for action, state and signal. */
|
||||
--color-amber: #E8913A;
|
||||
--color-amber-deep: #C4741F;
|
||||
--color-amber-soft: #F0B476;
|
||||
--color-amber-tint: rgba(232, 145, 58, 0.10);
|
||||
--color-amber-ring: rgba(232, 145, 58, 0.22);
|
||||
|
||||
/* Semantic aliases — prefer these in component code. */
|
||||
--surface-page: var(--color-stone);
|
||||
--surface-card: var(--color-stone-2);
|
||||
--surface-card-hover: var(--color-stone-3);
|
||||
--surface-dark: var(--color-slate);
|
||||
--surface-dark-card: var(--color-slate-2);
|
||||
|
||||
--text-body: var(--color-ink-body);
|
||||
--text-muted: var(--color-ink-muted);
|
||||
--text-dim: var(--color-ink-dim);
|
||||
--text-accent: var(--color-amber-deep);
|
||||
--text-on-accent: var(--color-ink-body);
|
||||
|
||||
--border-hairline: var(--color-stone-rule);
|
||||
--border-strong: var(--color-ink-muted);
|
||||
|
||||
--action-fill: var(--color-amber);
|
||||
--action-fill-hover: var(--color-amber-deep);
|
||||
--focus-ring: var(--color-amber-ring);
|
||||
--focus-border: var(--color-amber);
|
||||
--signal-error: var(--color-amber-deep);
|
||||
}
|
||||
|
||||
/* Slate scope — inside a dark block the ramps invert. Apply to the block, not the page. */
|
||||
[data-surface="slate"] {
|
||||
--surface-page: var(--color-slate);
|
||||
--surface-card: var(--color-slate-2);
|
||||
--surface-card-hover: var(--color-slate-3);
|
||||
--text-body: var(--color-neutral-1);
|
||||
--text-muted: var(--color-neutral-2);
|
||||
--text-dim: var(--color-neutral-4);
|
||||
--text-accent: var(--color-amber);
|
||||
--border-hairline: var(--color-slate-3);
|
||||
--border-strong: var(--color-neutral-3);
|
||||
}
|
||||
8
docs/design/tokens/elevation.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/* Elevation is carried by the stone ramp, not by shadow. Shadows exist only for layers that
|
||||
cross the page plane (dropdown, modal) and for the amber focus ring. */
|
||||
:root {
|
||||
--shadow-none: none;
|
||||
--shadow-dropdown: 0 4px 16px rgba(23, 28, 34, 0.10);
|
||||
--shadow-modal: 0 16px 48px rgba(23, 28, 34, 0.18);
|
||||
--ring-focus: 0 0 0 2px var(--color-amber-ring);
|
||||
}
|
||||
10
docs/design/tokens/fonts.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Kade type stack — IBM Plex Mono (display + labels) and IBM Plex Sans (body + UI).
|
||||
No licensed binaries shipped with the source skills, so the families load from Google Fonts.
|
||||
Replace this @import with local @font-face rules once real woff2 files are supplied. */
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap");
|
||||
|
||||
:root {
|
||||
--font-display: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--font-body: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
10
docs/design/tokens/motion.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* Colour shifts only. Kade never translates, lifts, or scales on hover. */
|
||||
:root {
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */
|
||||
--duration-fast: 120ms; /* @kind other */
|
||||
--duration-base: 160ms; /* @kind other */
|
||||
--duration-slow: 260ms; /* @kind other */
|
||||
--transition-color: color var(--duration-base) var(--ease-out),
|
||||
background-color var(--duration-base) var(--ease-out),
|
||||
border-color var(--duration-base) var(--ease-out);
|
||||
}
|
||||
11
docs/design/tokens/radius.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* Kade is rectangular. A rounded card is an antipattern. */
|
||||
:root {
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 8px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--border-width: 1px;
|
||||
--border-width-accent: 2px;
|
||||
}
|
||||
21
docs/design/tokens/spacing.css
Normal file
@@ -0,0 +1,21 @@
|
||||
/* 4px base step. Section rhythm is deliberately generous; the calm comes from air, not decoration. */
|
||||
:root {
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
--space-16: 64px;
|
||||
--space-20: 80px;
|
||||
--space-24: 96px;
|
||||
|
||||
--gutter: var(--space-6);
|
||||
--section-y: var(--space-24);
|
||||
--section-y-tight: var(--space-16);
|
||||
--container: 1140px;
|
||||
--measure: 68ch;
|
||||
}
|
||||
31
docs/design/tokens/typography.css
Normal file
@@ -0,0 +1,31 @@
|
||||
/* One scale for display (Plex Mono 700) and body (Plex Sans). Line-height tightens as size grows. */
|
||||
:root {
|
||||
--text-2xs: 10px;
|
||||
--text-xs: 11px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 15px;
|
||||
--text-md: 17px;
|
||||
--text-lg: 20px;
|
||||
--text-xl: 24px;
|
||||
--text-2xl: 32px;
|
||||
--text-4xl: 44px;
|
||||
--text-6xl: 58px;
|
||||
--text-7xl: 76px;
|
||||
|
||||
--leading-display: 1.02;
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.3;
|
||||
--leading-normal: 1.6;
|
||||
--leading-loose: 1.7;
|
||||
|
||||
/* Plex Mono display tracking is negative; label tracking is wide. */
|
||||
--tracking-display: -0.04em;
|
||||
--tracking-heading: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-label: 0.14em;
|
||||
|
||||
--weight-body: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-display: 700;
|
||||
}
|
||||
44
electron-builder.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
appId: com.blijnder.helder
|
||||
productName: Helder
|
||||
directories:
|
||||
output: dist
|
||||
buildResources: build
|
||||
# The renderer/main/preload are already bundled into out/ by electron-vite.
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
# Native / spawned binaries must live outside the asar to load at runtime.
|
||||
# The rg binary ships in a platform package (@vscode/ripgrep-<platform>-<arch>),
|
||||
# so that must be unpacked too — not just the @vscode/ripgrep shim.
|
||||
asarUnpack:
|
||||
- '**/node_modules/node-pty/**'
|
||||
- '**/node_modules/@vscode/ripgrep/**'
|
||||
- '**/node_modules/@vscode/ripgrep-*/**'
|
||||
# Ad-hoc signs the .app on macOS. Without it the bundle keeps only Electron's
|
||||
# linker signature, seals no resources, and macOS blocks it as malware.
|
||||
afterPack: build/adhoc-sign.cjs
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
# Local/unsigned build: ad-hoc signed by electron-builder, no notarization.
|
||||
identity: null
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
files:
|
||||
# Keep the cross-build leftovers (see win:) out of the mac package.
|
||||
- '!**/node_modules/@vscode/ripgrep-win32-*/**'
|
||||
win:
|
||||
target: nsis
|
||||
# Cross-built from macOS with `-c.npmRebuild=false`: node-pty can't be
|
||||
# cross-compiled, but it ships prebuilds/win32-* and its loader falls back
|
||||
# to those when build/Release is absent. The win32 rg binary is provided by
|
||||
# manually extracting @vscode/ripgrep-win32-x64 into node_modules (npm
|
||||
# refuses to install it on darwin).
|
||||
files:
|
||||
- '!**/node_modules/node-pty/build/**'
|
||||
- '!**/node_modules/@vscode/ripgrep-darwin-*/**'
|
||||
- '!**/node_modules/@vscode/ripgrep-linux-*/**'
|
||||
linux:
|
||||
target: AppImage
|
||||
category: Development
|
||||
30
electron.vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: { outDir: 'out/main' },
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
outDir: 'out/preload',
|
||||
// CommonJS preload (.cjs): loads synchronously before the page, so the
|
||||
// contextBridge is exposed by the time the renderer mounts. ESM preload
|
||||
// (.mjs) can expose late and leave window.helder briefly undefined.
|
||||
rollupOptions: { output: { format: 'cjs', entryFileNames: 'index.cjs' } },
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: 'src/renderer',
|
||||
build: {
|
||||
outDir: 'out/renderer',
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'src/renderer/index.html'),
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
},
|
||||
})
|
||||
46
eslint.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['out/**', 'dist/**', 'node_modules/**', 'coverage/**', 'design_handoff_helder_workbench/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: { ...globals.browser } },
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// These newer rules fire on the canonical "load async then setState in a
|
||||
// mount effect" / "read a stable bridge in an effect" patterns we use
|
||||
// deliberately. Keep the useful exhaustive-deps warnings; drop these.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'electron.vite.config.ts', 'vitest.config.ts', 'eslint.config.js'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
{
|
||||
files: ['test/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||
},
|
||||
{
|
||||
// electron-builder hooks: plain CommonJS, run by node outside the app.
|
||||
files: ['build/**/*.cjs'],
|
||||
languageOptions: { globals: { ...globals.node }, sourceType: 'commonjs' },
|
||||
rules: { '@typescript-eslint/no-require-imports': 'off' },
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
)
|
||||
BIN
jetbrains-mono-latin-400-normal-V6pRDFza.woff2
Normal file
8930
package-lock.json
generated
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "helder",
|
||||
"version": "0.1.0",
|
||||
"description": "Helder — dark-only Electron code workbench for reviewing AI-written code",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "Jonathan van Rij",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"rebuild": "electron-rebuild -f -w node-pty",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"postinstall": "electron-rebuild -f -w node-pty"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/ibm-plex-sans": "^5.3.0",
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"simple-git": "^3.36.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"electron": "^31.3.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^5.3.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
BIN
releases/Helder-0.1.0-arm64.dmg
Normal file
BIN
releases/helder-win-v0.1.0.zip
Normal file
2
screenshots/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
204
src/main/config.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Project-scoped settings, living in `.helder/` in the opened project's root.
|
||||
* - config.default.json full built-in defaults, REGENERATED on every launch
|
||||
* (live documentation; the app never reads user edits here)
|
||||
* - config.json sparse — only user-overridden values
|
||||
* - theme.css custom CSS over the built-in dark theme; the CODE FONT
|
||||
* and FONT SIZE live here (as CSS vars), not in the JSON
|
||||
* 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'
|
||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||
|
||||
/** xterm's colour table. Every value is a CSS colour; the selection entries
|
||||
* may carry alpha, the rest may not. */
|
||||
export interface TerminalTheme {
|
||||
background: string; foreground: string; cursor: string; cursorAccent: string
|
||||
selectionBackground: string; selectionInactiveBackground: string
|
||||
black: string; red: string; green: string; yellow: string
|
||||
blue: string; magenta: string; cyan: string; white: string
|
||||
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
|
||||
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
|
||||
}
|
||||
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: {
|
||||
/** Login shell for both panes. null = $SHELL. */
|
||||
shell: string | null
|
||||
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
|
||||
fontFamily: string | null
|
||||
fontSize: number | null
|
||||
lineHeight: number
|
||||
letterSpacing: number
|
||||
cursorStyle: 'bar' | 'block' | 'underline'
|
||||
cursorBlink: boolean
|
||||
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
|
||||
* which stops a CLI's bold labels from washing out. */
|
||||
boldIsBright: boolean
|
||||
scrollback: number
|
||||
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
|
||||
* Option from typing accented characters, so it is off by default. */
|
||||
optionIsMeta: boolean
|
||||
theme: TerminalTheme
|
||||
}
|
||||
session: { restoreOnLaunch: boolean }
|
||||
}
|
||||
|
||||
export const DEFAULTS: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
|
||||
files: { exclude: [], followGitignore: false },
|
||||
terminal: {
|
||||
shell: null,
|
||||
fontFamily: null,
|
||||
fontSize: null,
|
||||
lineHeight: 1.7,
|
||||
letterSpacing: 0,
|
||||
cursorStyle: 'bar',
|
||||
cursorBlink: true,
|
||||
boldIsBright: false,
|
||||
scrollback: 8000,
|
||||
optionIsMeta: false,
|
||||
// The app's own palette: the editor ground, amber on the caret and the
|
||||
// selection, and the six muted syntax colours on the ANSI table, so a diff
|
||||
// in the terminal reads like a diff in the editor. Red is amber-deep and
|
||||
// green is teal — the same pair the diff views use. A terminal still needs
|
||||
// eight distinguishable slots, so blue and cyan take two cool tones that
|
||||
// stay inside the muted register.
|
||||
theme: {
|
||||
background: '#101720',
|
||||
foreground: '#E4E7E6',
|
||||
cursor: '#E8913A',
|
||||
cursorAccent: '#101720',
|
||||
selectionBackground: 'rgba(232,145,58,0.22)',
|
||||
selectionInactiveBackground: 'rgba(232,145,58,0.12)',
|
||||
black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
|
||||
blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
|
||||
brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
|
||||
brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
|
||||
},
|
||||
},
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
* This file is created once and never overwritten; edit it freely.
|
||||
* The code font and font size live here (not in config.json). Uncomment and
|
||||
* tweak any variable below; you can also override any --token from the built-in
|
||||
* theme (see the design tokens in the app's styles). */
|
||||
:root {
|
||||
/* Code surfaces (editor + terminals) */
|
||||
/* --code-font: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
||||
/* --code-size: 13px; */ /* editor font size */
|
||||
/* --term-size: 12px; */ /* terminal font size, unless config.json sets one */
|
||||
|
||||
/* The terminal's colours, cursor and scrollback live in config.json, under
|
||||
"terminal" — the palette is JS options, not CSS, because xterm paints to a
|
||||
canvas. Edit either file and the running terminals restyle themselves. */
|
||||
|
||||
/* Example accent override: */
|
||||
/* --accent: #E8913A; */
|
||||
}
|
||||
`
|
||||
|
||||
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
||||
const RECENT_FILE = 'recent.json'
|
||||
const MAX_RECENT = 100
|
||||
// The whole .helder folder is local, machine-specific state — ignore all of it.
|
||||
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit).\n*\n`
|
||||
|
||||
let current: HelderConfig = DEFAULTS
|
||||
let themeCss = ''
|
||||
|
||||
/** Ensure `.helder/.gitignore` ignores the entire folder; (re)write it when the
|
||||
* file is missing or out of date (e.g. upgrading from the old recent-only one). */
|
||||
async function ensureGitignore(dir: string): Promise<void> {
|
||||
const path = join(dir, '.gitignore')
|
||||
try {
|
||||
if ((await readFile(path, 'utf8')) === GITIGNORE_BODY) return
|
||||
} catch {
|
||||
/* missing — fall through to write */
|
||||
}
|
||||
await writeFile(path, GITIGNORE_BODY)
|
||||
}
|
||||
|
||||
export async function getRecent(root: string): Promise<string[]> {
|
||||
try {
|
||||
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
|
||||
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRecent(root: string, list: string[]): Promise<void> {
|
||||
try {
|
||||
const dir = join(root, '.helder')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await ensureGitignore(dir)
|
||||
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
|
||||
} catch {
|
||||
/* read-only / inaccessible root — recents just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
|
||||
function deepMerge<T>(base: T, over: unknown): T {
|
||||
if (!isPlainObject(base) || !isPlainObject(over)) return base
|
||||
const out: Record<string, unknown> = { ...base }
|
||||
for (const key of Object.keys(over)) {
|
||||
const b = (base as Record<string, unknown>)[key]
|
||||
const o = over[key]
|
||||
if (isPlainObject(b) && isPlainObject(o)) out[key] = deepMerge(b, o)
|
||||
else if (o !== undefined) out[key] = o
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
|
||||
/** (Re)resolve config + theme for a project root, regenerating the defaults file. */
|
||||
export async function resolveConfig(root: string): Promise<void> {
|
||||
const dir = join(root, '.helder')
|
||||
try {
|
||||
await mkdir(dir, { recursive: true })
|
||||
// Always regenerate the defaults file — it documents every setting.
|
||||
await writeFile(join(dir, 'config.default.json'), JSON.stringify(DEFAULTS, null, 2) + '\n')
|
||||
|
||||
let override: unknown = {}
|
||||
try { override = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) } catch { /* none / invalid */ }
|
||||
current = deepMerge(DEFAULTS, override)
|
||||
|
||||
try {
|
||||
themeCss = await readFile(join(dir, 'theme.css'), 'utf8')
|
||||
} catch {
|
||||
themeCss = THEME_TEMPLATE
|
||||
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
|
||||
}
|
||||
|
||||
await ensureGitignore(dir)
|
||||
} catch {
|
||||
// Read-only / inaccessible root: fall back to built-in defaults.
|
||||
current = DEFAULTS
|
||||
themeCss = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): HelderConfig {
|
||||
return current
|
||||
}
|
||||
|
||||
export function getThemeCss(): string {
|
||||
return themeCss
|
||||
}
|
||||
211
src/main/diagnostics.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
||||
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
||||
|
||||
/**
|
||||
* Everything that turns a silent death into a log line. Wires the process-,
|
||||
* app- and window-level failure hooks Electron gives us, none of which were
|
||||
* connected before — which is why crashes left no trace.
|
||||
*
|
||||
* The hooks, and the crash each one actually catches:
|
||||
* uncaughtException / unhandledRejection → a throw in OUR main-process code
|
||||
* render-process-gone → the renderer died (OOM, segfault):
|
||||
* the classic "window went blank//white"
|
||||
* child-process-gone → GPU / utility process died
|
||||
* preload-error → preload threw: `window.helder` is
|
||||
* undefined and the app silently falls
|
||||
* back to MOCK DATA (see CLAUDE.md)
|
||||
* unresponsive → main thread wedged (the beachball)
|
||||
* crashReporter minidumps → NATIVE crashes (node-pty is native,
|
||||
* and a segfault there takes the whole
|
||||
* process down with no JS hook at all)
|
||||
*/
|
||||
|
||||
let fatalDialogOpen = false
|
||||
|
||||
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
|
||||
* native crashes, and the log file should exist before anything can fail. */
|
||||
export function initDiagnostics(isDev: boolean): void {
|
||||
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
||||
// 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')
|
||||
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 })
|
||||
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
|
||||
// uploaded anywhere (there is no server, and this is a personal tool).
|
||||
try {
|
||||
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
|
||||
} catch (e) {
|
||||
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
|
||||
}
|
||||
|
||||
logger.info('session', 'starting', {
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
packaged: app.isPackaged,
|
||||
dev: isDev,
|
||||
crashDumps: app.getPath('crashDumps'),
|
||||
argv: process.argv.slice(1),
|
||||
project: process.env.HELDER_PROJECT ?? null,
|
||||
})
|
||||
|
||||
installProcessHooks()
|
||||
installAppHooks()
|
||||
}
|
||||
|
||||
function installProcessHooks(): void {
|
||||
process.on('uncaughtException', (err, origin) => {
|
||||
logger.error('fatal', `uncaughtException (${origin})`, err)
|
||||
showFatal(err)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
|
||||
// handler shows up — and the stack here is the only place the cause exists.
|
||||
logger.error('fatal', 'unhandledRejection', reason)
|
||||
})
|
||||
|
||||
process.on('warning', (w) => {
|
||||
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
|
||||
// a leak-driven crash.
|
||||
logger.warn('node', w.name, { message: w.message, stack: w.stack })
|
||||
})
|
||||
|
||||
app.on('before-quit', () => logger.info('session', 'quitting'))
|
||||
}
|
||||
|
||||
function installAppHooks(): void {
|
||||
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
|
||||
// 'killed', 'launch-failed'.
|
||||
app.on('render-process-gone', (_e, contents, details) => {
|
||||
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
reason: details.reason,
|
||||
url: safeUrl(contents),
|
||||
})
|
||||
if (details.reason !== 'clean-exit') {
|
||||
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
|
||||
}
|
||||
})
|
||||
|
||||
app.on('child-process-gone', (_e, details) => {
|
||||
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName,
|
||||
name: details.name,
|
||||
})
|
||||
})
|
||||
|
||||
// A preload failure is silent-by-design in Electron and the single nastiest
|
||||
// failure mode this app has: no window.helder → the renderer quietly serves
|
||||
// mock data and "terminal not available", as if nothing were wrong.
|
||||
app.on('web-contents-created', (_e, contents) => {
|
||||
contents.on('preload-error', (_ev, preloadPath, error) => {
|
||||
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
|
||||
})
|
||||
let sawRoLoop = false
|
||||
contents.on('console-message', (...a: unknown[]) => {
|
||||
// Electron ≥36 passes a single event object; older versions pass
|
||||
// (event, level, message, line, sourceId). Support both so a version bump
|
||||
// doesn't quietly stop capturing renderer console output.
|
||||
const d = normaliseConsoleMessage(a)
|
||||
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
|
||||
// "ResizeObserver loop …" is a browser layout notice, not a fault, and it
|
||||
// fires once per frame — a window drag would bury everything else. The
|
||||
// renderer suppresses its own repeats the same way (see log.ts).
|
||||
if (d.message.startsWith('ResizeObserver loop')) {
|
||||
if (sawRoLoop) return
|
||||
sawRoLoop = true
|
||||
log('warn', 'console', d.message + ' (browser layout notice; repeats suppressed)', { source: d.source, line: d.line })
|
||||
return
|
||||
}
|
||||
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Electron changed the console-message signature in v36; accept both shapes. */
|
||||
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
|
||||
const first = args[0] as Record<string, unknown> | undefined
|
||||
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
|
||||
const lvl = first.level
|
||||
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
|
||||
return {
|
||||
level: asNum,
|
||||
message: String(first.message),
|
||||
source: String(first.sourceId ?? ''),
|
||||
line: Number(first.lineNumber ?? 0),
|
||||
}
|
||||
}
|
||||
if (args.length >= 3 && typeof args[1] === 'number') {
|
||||
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function safeUrl(contents: WebContents | null): string {
|
||||
try { return contents?.getURL() ?? '' } catch { return '' }
|
||||
}
|
||||
|
||||
/** Watch for the beachball: log it (with a stack-free note) rather than let the
|
||||
* user guess whether the app is hung or just slow. */
|
||||
export function watchWindow(win: BrowserWindow): void {
|
||||
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
|
||||
win.on('responsive', () => logger.info('window', 'responsive again'))
|
||||
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the user something died, and put the log one click away — a crash the
|
||||
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
|
||||
* stack a hundred dialogs.
|
||||
*/
|
||||
function showFatal(err: unknown): void {
|
||||
if (fatalDialogOpen) return
|
||||
fatalDialogOpen = true
|
||||
const { message } = formatErr(err)
|
||||
const logPath = getLogPath()
|
||||
Promise.resolve(dialog.showMessageBox({
|
||||
type: 'error',
|
||||
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
|
||||
defaultId: 0,
|
||||
cancelId: logPath ? 1 : 0,
|
||||
message: 'Helder hit an error',
|
||||
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
|
||||
})).then(({ response }) => {
|
||||
if (logPath && response === 0) openLog()
|
||||
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
|
||||
.finally(() => { fatalDialogOpen = false })
|
||||
}
|
||||
|
||||
/** Open the log in the default text editor. */
|
||||
export function openLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.openPath(p).catch(() => {})
|
||||
}
|
||||
|
||||
/** Reveal the log folder (all rotated files + siblings) in Finder. */
|
||||
export function revealLog(): void {
|
||||
const p = getLogPath()
|
||||
if (p) shell.showItemInFolder(p)
|
||||
}
|
||||
|
||||
export { getLogDir, getLogPath }
|
||||
357
src/main/fs-service.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { listFiles, rgAvailable } from './search-service'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
type: 'dir' | 'file'
|
||||
path: string
|
||||
open?: boolean
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
/** Directories never walked by the fallback (rg already honors these as globs). */
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
])
|
||||
|
||||
const MAX_FILE_BYTES = 300_000
|
||||
const MAX_INDEXED_FILES = 6000
|
||||
|
||||
function ignored(name: string): boolean {
|
||||
return IGNORE_DIRS.has(name) || name === '.DS_Store'
|
||||
}
|
||||
|
||||
function looksBinary(buf: Buffer): boolean {
|
||||
const n = Math.min(buf.length, 8000)
|
||||
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- tree from a flat path list (the rg-backed primary path) ----------------
|
||||
|
||||
function sortTree(node: FileNode): void {
|
||||
if (!node.children) return
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
for (const c of node.children) sortTree(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested tree from relative file paths (dirs first, alphabetical).
|
||||
* `dirPaths` are directories to force into the tree even when they hold no
|
||||
* files — empty folders that `rg --files` can never emit (see `listEmptyDirs`).
|
||||
*/
|
||||
export function buildTreeFromPaths(rootName: string, paths: string[], dirPaths: string[] = []): FileNode {
|
||||
const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
|
||||
const dirs = new Map<string, FileNode>([['', root]])
|
||||
|
||||
/** Ensure a directory node (and all its ancestors) exist; return the node. */
|
||||
function ensureDir(rel: string): FileNode {
|
||||
const existing = dirs.get(rel)
|
||||
if (existing) return existing
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
let parent = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||
let dir = dirs.get(curPath)
|
||||
if (!dir) {
|
||||
dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
|
||||
dirs.set(curPath, dir)
|
||||
parent.children!.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
parentPath = curPath
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
for (const rel of paths) {
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
if (!parts.length) continue
|
||||
const parent = ensureDir(parts.slice(0, -1).join('/'))
|
||||
parent.children!.push({ name: parts[parts.length - 1], type: 'file', path: rel })
|
||||
}
|
||||
for (const rel of dirPaths) if (rel) ensureDir(rel)
|
||||
sortTree(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function rootName(root: string): string {
|
||||
return root.split(sep).filter(Boolean).pop() || root
|
||||
}
|
||||
|
||||
/** Project tree. Primary: rg file list (honors gitignore + excludes) plus the
|
||||
* empty folders rg can't emit. Fallback: a plain recursive walk (when ripgrep
|
||||
* is unavailable) — that already lists empty dirs. */
|
||||
export async function readTree(root: string): Promise<FileNode> {
|
||||
const [paths, emptyDirs] = await Promise.all([
|
||||
listFiles(root).catch(() => [] as string[]),
|
||||
listEmptyDirs(root).catch(() => [] as string[]),
|
||||
])
|
||||
if (paths.length || emptyDirs.length) return buildTreeFromPaths(rootName(root), paths, emptyDirs)
|
||||
return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative paths of directories whose entire subtree holds no files
|
||||
* ("file-empty" folders). `rg --files` lists files only, so an empty folder has
|
||||
* nothing for it to emit and the Explorer would never show it until its first
|
||||
* file lands (and not even after a restart). We inject these alongside the rg
|
||||
* list so a freshly created folder shows up immediately.
|
||||
*
|
||||
* Only *file-empty* dirs are injected, never a dir that contains files: a dir
|
||||
* with files is already represented by those files (gitignore-filtered by rg),
|
||||
* so this can never resurrect a gitignored content directory. Traversal honors
|
||||
* the same IGNORE_DIRS as the fallback walk. `scope` (an absolute dir inside
|
||||
* root) restricts the walk; returned paths stay relative to root.
|
||||
*/
|
||||
export async function listEmptyDirs(root: string, scope?: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
/** Walk `abs`; return whether its subtree contains at least one file. */
|
||||
async function walk(abs: string, depth: number): Promise<boolean> {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
let hasFile = false
|
||||
const subdirs: string[] = []
|
||||
for (const e of entries) {
|
||||
if (ignored(e.name)) continue
|
||||
if (e.isFile()) hasFile = true
|
||||
else if (e.isDirectory()) subdirs.push(join(abs, e.name))
|
||||
}
|
||||
for (const childAbs of subdirs) {
|
||||
const childHasFile = depth < 12 ? await walk(childAbs, depth + 1) : false
|
||||
if (childHasFile) hasFile = true
|
||||
else out.push(relative(root, childAbs).split(sep).join('/'))
|
||||
}
|
||||
return hasFile
|
||||
}
|
||||
await walk(scope || root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Find the node at a root-relative path inside a tree ('' is the root). */
|
||||
function findNode(tree: FileNode, rel: string): FileNode | null {
|
||||
if (rel === '') return tree
|
||||
let node: FileNode | null = tree
|
||||
for (const part of rel.split('/').filter(Boolean)) {
|
||||
node = node?.children?.find((c) => c.name === part) ?? null
|
||||
if (!node) return null
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh children for a single directory (root-relative path; '' = root). Used to
|
||||
* re-read a folder on expand/collapse so newly added/removed files show up
|
||||
* without a full tree walk. Stays consistent with the initial tree: rg-backed
|
||||
* (honors gitignore + excludes), with the recursive-walk fallback only when
|
||||
* ripgrep is unavailable.
|
||||
*/
|
||||
export async function readDirChildren(root: string, rel: string): Promise<FileNode[]> {
|
||||
const abs = rel ? join(root, rel) : root
|
||||
if (await rgAvailable()) {
|
||||
const [paths, emptyDirs] = await Promise.all([
|
||||
listFiles(root, abs).catch(() => [] as string[]),
|
||||
listEmptyDirs(root, abs).catch(() => [] as string[]),
|
||||
])
|
||||
return findNode(buildTreeFromPaths(rootName(root), paths, emptyDirs), rel)?.children ?? []
|
||||
}
|
||||
return readDir(abs, root, 0)
|
||||
}
|
||||
|
||||
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const dirs: FileNode[] = []
|
||||
const files: FileNode[] = []
|
||||
for (const e of entries) {
|
||||
if (ignored(e.name)) continue
|
||||
const childAbs = join(abs, e.name)
|
||||
const rel = relative(root, childAbs).split(sep).join('/')
|
||||
if (e.isDirectory()) {
|
||||
dirs.push({
|
||||
name: e.name, type: 'dir', path: rel, open: depth < 1,
|
||||
children: depth < 12 ? await readDir(childAbs, root, depth + 1) : [],
|
||||
})
|
||||
} else if (e.isFile()) {
|
||||
files.push({ name: e.name, type: 'file', path: rel })
|
||||
}
|
||||
}
|
||||
dirs.sort((a, b) => a.name.localeCompare(b.name))
|
||||
files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...dirs, ...files]
|
||||
}
|
||||
|
||||
/** Read a single text file (relative path) → string. */
|
||||
export async function readProjectFile(root: string, rel: string): Promise<string> {
|
||||
const buf = await readFile(join(root, rel))
|
||||
if (looksBinary(buf)) return ''
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
|
||||
ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng',
|
||||
}
|
||||
const MAX_IMAGE_BYTES = 25_000_000
|
||||
|
||||
/** Read an image file as a `data:` URL for the viewer's <img> — the renderer
|
||||
* can't touch the filesystem, and a data URL sidesteps file:// path/escaping
|
||||
* concerns entirely. Returns '' for a non-image extension, a path escaping the
|
||||
* root, or an oversized/unreadable file. */
|
||||
export async function readImageDataUrl(root: string, rel: string): Promise<string> {
|
||||
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
|
||||
const mime = IMAGE_MIME[ext]
|
||||
if (!mime) return ''
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) return ''
|
||||
try {
|
||||
const buf = await readFile(target)
|
||||
if (buf.length > MAX_IMAGE_BYTES) return ''
|
||||
return `data:${mime};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a text file (relative path). Used by the editable buffer's save. */
|
||||
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
|
||||
await writeFile(join(root, rel), content, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new, empty text file (relative path). Creates parent folders as
|
||||
* needed, refuses to escape the project root, and throws if the file already
|
||||
* exists so an accidental name collision never clobbers existing content.
|
||||
*/
|
||||
export async function createProjectFile(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
|
||||
const existing = await stat(target).catch(() => null)
|
||||
if (existing) throw new Error('file already exists')
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new, empty folder (relative path). Creates parent folders as needed,
|
||||
* refuses to escape the project root, and throws if the folder already exists so
|
||||
* a name collision is surfaced rather than silently swallowed. The empty folder
|
||||
* shows up in the tree immediately (see `listEmptyDirs`).
|
||||
*/
|
||||
export async function createProjectDir(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
|
||||
const existing = await stat(target).catch(() => null)
|
||||
if (existing) throw new Error('folder already exists')
|
||||
await mkdir(target, { recursive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a project file or folder. `rel` is the current relative path, `name`
|
||||
* the new basename (no slashes — a rename stays in the same folder). Returns the
|
||||
* new relative path. Refuses to escape the project root and throws if the target
|
||||
* name is already taken, so a rename never clobbers an existing file.
|
||||
*/
|
||||
export async function renameProjectEntry(root: string, rel: string, name: string): Promise<string> {
|
||||
const clean = name.trim().replace(/\/+$/, '')
|
||||
if (!clean || clean.includes('/') || clean === '.' || clean === '..') throw new Error('invalid name')
|
||||
const from = join(root, rel)
|
||||
const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
|
||||
const next = parent ? `${parent}/${clean}` : clean
|
||||
const to = join(root, next)
|
||||
if (relative(root, from).startsWith('..') || relative(root, to).startsWith('..')) throw new Error('outside project root')
|
||||
if (from === to) return rel
|
||||
// Case-only renames (foo.md → Foo.md) hit an existing path on macOS' case
|
||||
// insensitive filesystem, so only guard when the name really differs.
|
||||
const sameName = from.toLowerCase() === to.toLowerCase()
|
||||
if (!sameName && await stat(to).catch(() => null)) throw new Error('name already exists')
|
||||
await rename(from, to)
|
||||
return next
|
||||
}
|
||||
|
||||
/** Delete a project file or folder (relative path). Stays inside the project root. */
|
||||
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) return // guard against escaping the root
|
||||
await rm(target, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory content index of all (small, text) files — powers content viewing.
|
||||
* Primary: read the rg file list; fallback: walk. Capped for large repos.
|
||||
*/
|
||||
export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
const paths = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return readListed(root, paths)
|
||||
return readAllWalk(root)
|
||||
}
|
||||
|
||||
async function readListed(root: string, paths: string[]): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
for (const rel of paths) {
|
||||
if (count >= MAX_INDEXED_FILES) break
|
||||
try {
|
||||
const abs = join(root, rel)
|
||||
const s = await stat(abs)
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(abs)
|
||||
if (looksBinary(buf)) continue
|
||||
out[rel] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function readAllWalk(root: string): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
async function walk(abs: string): Promise<void> {
|
||||
if (count >= MAX_INDEXED_FILES) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (count >= MAX_INDEXED_FILES) return
|
||||
if (ignored(e.name)) continue
|
||||
const childAbs = join(abs, e.name)
|
||||
if (e.isDirectory()) {
|
||||
await walk(childAbs)
|
||||
} else if (e.isFile()) {
|
||||
try {
|
||||
const s = await stat(childAbs)
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(childAbs)
|
||||
if (looksBinary(buf)) continue
|
||||
out[relative(root, childAbs).split(sep).join('/')] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root)
|
||||
return out
|
||||
}
|
||||
327
src/main/git-service.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
/** Map `fn` over `items` with at most `limit` running at once, preserving order.
|
||||
* Keeps the per-file git/disk reads overlapping without spawning hundreds of
|
||||
* subprocesses at once (macOS has a low default open-file limit). */
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length)
|
||||
let next = 0
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < items.length) {
|
||||
const i = next++
|
||||
out[i] = await fn(items[i], i)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
||||
return out
|
||||
}
|
||||
|
||||
export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
|
||||
|
||||
export interface GitChange {
|
||||
path: string
|
||||
status: GitStatusLetter
|
||||
staged: boolean
|
||||
original: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface GitLoad {
|
||||
branch: string
|
||||
changes: GitChange[]
|
||||
}
|
||||
|
||||
interface GitResult {
|
||||
code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git subcommand directly via child_process.
|
||||
*
|
||||
* We deliberately spawn `git` ourselves instead of going through simple-git:
|
||||
* inside Electron's main process simple-git's spawn (which wires up a stdin
|
||||
* pipe) trips `spawn EBADF` on macOS. Forcing stdin to 'ignore' — the child
|
||||
* never reads input — sidesteps the bad-descriptor crash entirely.
|
||||
*/
|
||||
function runGit(root: string, args: string[]): Promise<GitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd: root,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (d: string) => { stdout += d })
|
||||
child.stderr.on('data', (d: string) => { stderr += d })
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Run git, rejecting on a non-zero exit. */
|
||||
async function git(root: string, args: string[]): Promise<string> {
|
||||
const { code, stdout, stderr } = await runGit(root, args)
|
||||
if (code !== 0) throw new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** Map one porcelain status code to our display letter. */
|
||||
function letterFor(code: string): GitStatusLetter {
|
||||
switch (code) {
|
||||
case 'A': case 'C': case '?': return 'A'
|
||||
case 'D': return 'D'
|
||||
case 'R': return 'R'
|
||||
case 'U': return 'M'
|
||||
default: return 'M'
|
||||
}
|
||||
}
|
||||
|
||||
/** A merge conflict. Git reports both sides, but neither half can be staged on
|
||||
* its own, so a conflict stays one row. */
|
||||
function isConflict(index: string, working: string): boolean {
|
||||
return index === 'U' || working === 'U'
|
||||
|| (index === 'A' && working === 'A')
|
||||
|| (index === 'D' && working === 'D')
|
||||
}
|
||||
|
||||
export interface GitRowSpec { letter: GitStatusLetter; staged: boolean }
|
||||
|
||||
/**
|
||||
* Split a porcelain code pair into the rows the git panel shows.
|
||||
*
|
||||
* A file can be staged AND changed again on disk. Git reports that as "MM".
|
||||
* That is two rows: one staged (HEAD vs index) and one unstaged (index vs
|
||||
* disk). Folding it into a single row hid the newer edit completely.
|
||||
*/
|
||||
export function classify(index: string, working: string): GitRowSpec[] {
|
||||
if (isConflict(index, working)) return [{ letter: 'M', staged: true }]
|
||||
const rows: GitRowSpec[] = []
|
||||
if (index !== ' ' && index !== '?') rows.push({ letter: letterFor(index), staged: true })
|
||||
if (working !== ' ') rows.push({ letter: letterFor(working), staged: false })
|
||||
// Should not happen (git does not report a clean file), but never drop an entry.
|
||||
return rows.length ? rows : [{ letter: letterFor(index), staged: true }]
|
||||
}
|
||||
|
||||
/** Parse a `## ...` porcelain branch header into a display branch name. */
|
||||
function parseBranch(header: string): string {
|
||||
// e.g. "main...origin/main [ahead 1]", "main", "No commits yet on main",
|
||||
// "HEAD (no branch)".
|
||||
const noCommits = header.match(/^No commits yet on (.+)$/)
|
||||
if (noCommits) return noCommits[1].trim()
|
||||
if (header.startsWith('HEAD ')) return 'HEAD'
|
||||
const upstream = header.indexOf('...')
|
||||
const head = upstream >= 0 ? header.slice(0, upstream) : header
|
||||
return head.split(' ')[0].trim() || 'HEAD'
|
||||
}
|
||||
|
||||
interface StatusEntry { index: string; working: string; path: string }
|
||||
|
||||
interface ParsedStatus { branch: string; files: StatusEntry[] }
|
||||
|
||||
/** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */
|
||||
export function parseStatus(raw: string): ParsedStatus {
|
||||
const parts = raw.split('\0')
|
||||
let branch = 'HEAD'
|
||||
const files: StatusEntry[] = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i]
|
||||
if (!p) continue
|
||||
if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue }
|
||||
const index = p[0]
|
||||
const working = p[1]
|
||||
const path = p.slice(3) // skip "XY "
|
||||
// For renames/copies the original path follows as its own NUL field; the
|
||||
// destination (this entry's path) is what we display, so just skip it.
|
||||
if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++
|
||||
files.push({ index, working, path })
|
||||
}
|
||||
return { branch, files }
|
||||
}
|
||||
|
||||
async function headText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
return await git(root, ['show', `HEAD:${path}`])
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** The staged copy of a file: the blob sitting in the index. */
|
||||
async function indexText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
return await git(root, ['show', `:${path}`])
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function diskText(root: string, path: string): Promise<string> {
|
||||
try {
|
||||
const buf = await readFile(join(root, path))
|
||||
// skip obvious binaries
|
||||
for (let i = 0; i < Math.min(buf.length, 8000); i++) if (buf[i] === 0) return ''
|
||||
return buf.toString('utf8')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function isRepo(root: string): Promise<boolean> {
|
||||
try {
|
||||
const { code, stdout } = await runGit(root, ['rev-parse', '--is-inside-work-tree'])
|
||||
if (code === 0 && stdout.trim() === 'true') return true
|
||||
} catch {
|
||||
/* git missing / transient — fall back to disk. */
|
||||
}
|
||||
// An external branch switch briefly rewrites .git; trust its presence on
|
||||
// disk rather than blanking the whole panel on a momentary probe failure.
|
||||
return existsSync(join(root, '.git'))
|
||||
}
|
||||
|
||||
// Coalesce overlapping loads per root: a branch switch or rapid edits fire
|
||||
// several watcher pings, each triggering a reload. We keep at most one read in
|
||||
// flight plus one trailing read (which captures whatever changed during the
|
||||
// first), so the panel always settles on fresh state without a spawn pile-up.
|
||||
const loadInFlight = new Map<string, Promise<GitLoad | null>>()
|
||||
const loadPending = new Map<string, Promise<GitLoad | null>>()
|
||||
|
||||
export function load(root: string): Promise<GitLoad | null> {
|
||||
const running = loadInFlight.get(root)
|
||||
if (running) {
|
||||
let pending = loadPending.get(root)
|
||||
if (!pending) {
|
||||
pending = running.catch(() => {}).then(() => {
|
||||
loadPending.delete(root)
|
||||
return startLoad(root)
|
||||
})
|
||||
loadPending.set(root, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
return startLoad(root)
|
||||
}
|
||||
|
||||
function startLoad(root: string): Promise<GitLoad | null> {
|
||||
const p = doLoad(root).finally(() => {
|
||||
if (loadInFlight.get(root) === p) loadInFlight.delete(root)
|
||||
})
|
||||
loadInFlight.set(root, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function doLoad(root: string): Promise<GitLoad | null> {
|
||||
if (!(await isRepo(root))) return null
|
||||
|
||||
// A branch switch / checkout from an external tool (Sublime Merge, the CLI)
|
||||
// rewrites .git and briefly holds .git/index.lock; a status that lands in
|
||||
// that window fails. Retry so we settle on the real new state instead of
|
||||
// rejecting (which would leave the git column stale or blank).
|
||||
let raw: string | null = null
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
try {
|
||||
raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z'])
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 5) throw err
|
||||
await delay(120)
|
||||
}
|
||||
}
|
||||
if (raw == null) return null
|
||||
|
||||
const { branch, files } = parseStatus(raw)
|
||||
// Each changed file needs its HEAD blob (a `git show` spawn) + its disk text.
|
||||
// Done serially this is O(files) subprocess spawns in a row — staging one file
|
||||
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
|
||||
// bounded concurrency instead so the spawns overlap (cap keeps us well under
|
||||
// macOS's low default FD limit). Order is preserved by index.
|
||||
const changes = (await mapLimit(files, 12, async (f) => {
|
||||
const rows = classify(f.index, f.working)
|
||||
const stagedRow = rows.find((r) => r.staged)
|
||||
const workRow = rows.find((r) => !r.staged)
|
||||
// The index blob is only needed when a file sits in BOTH groups. With one
|
||||
// row the index copy equals HEAD (unstaged only) or the disk copy (staged
|
||||
// only), so the common case still costs no extra `git show`.
|
||||
const both = !!stagedRow && !!workRow
|
||||
const idx = both ? await indexText(root, f.path) : ''
|
||||
const out: GitChange[] = []
|
||||
if (stagedRow) {
|
||||
// Staged row: HEAD -> index.
|
||||
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
|
||||
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
|
||||
}
|
||||
if (workRow) {
|
||||
// Unstaged row: index -> disk. An untracked file has no index copy.
|
||||
const untracked = f.index === '?'
|
||||
const original = untracked ? '' : both ? idx : await headText(root, f.path)
|
||||
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
|
||||
}
|
||||
return out
|
||||
})).flat()
|
||||
|
||||
return { branch, changes }
|
||||
}
|
||||
|
||||
export async function stage(root: string, paths: string[]): Promise<void> {
|
||||
// `git add` stages modifications, additions AND deletions of the given paths.
|
||||
await git(root, ['add', '--', ...paths])
|
||||
}
|
||||
|
||||
export async function unstage(root: string, paths: string[]): Promise<void> {
|
||||
const { code } = await runGit(root, ['reset', '--', ...paths])
|
||||
if (code !== 0) {
|
||||
// empty repo (no HEAD yet): fall back to removing from the index.
|
||||
await git(root, ['rm', '--cached', '-r', '--', ...paths])
|
||||
}
|
||||
}
|
||||
|
||||
export async function commit(root: string, message: string): Promise<void> {
|
||||
await git(root, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current branch to its remote. If the branch has no upstream yet,
|
||||
* retry with `-u origin <branch>` so the first push also sets tracking.
|
||||
* Returns a concise one-line summary for the toast (git writes progress to
|
||||
* stderr, so we pull the summary from there).
|
||||
*/
|
||||
export async function push(root: string): Promise<{ ok: boolean; message: string }> {
|
||||
let res = await runGit(root, ['push'])
|
||||
if (res.code !== 0 && /no upstream branch|set-upstream/i.test(res.stderr)) {
|
||||
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
|
||||
if (branch && branch !== 'HEAD') res = await runGit(root, ['push', '-u', 'origin', branch])
|
||||
}
|
||||
const lines = (res.stderr || res.stdout).trim().split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (res.code !== 0) return { ok: false, message: lines.pop() || 'push failed' }
|
||||
const summary = lines.find((l) => /->|up-to-date|new branch/i.test(l)) || lines.pop() || 'Pushed'
|
||||
return { ok: true, message: summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard working-tree changes for each path:
|
||||
* - exists in HEAD → restore index + worktree to the last commit
|
||||
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk
|
||||
*/
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
for (const p of paths) {
|
||||
const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`])
|
||||
if (code === 0) {
|
||||
await git(root, ['checkout', 'HEAD', '--', p])
|
||||
} else {
|
||||
await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result
|
||||
await rm(join(root, p), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
454
src/main/index.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||
import { watch, type FSWatcher } from 'chokidar'
|
||||
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 { commit, discard, load, push, stage, unstage } from './git-service'
|
||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||
import { listFiles, searchContent } from './search-service'
|
||||
import { invalidateSymbols, lookupSymbol, symbolNames } from './symbols-service'
|
||||
import { readNote, writeNote } from './notes-service'
|
||||
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
|
||||
import { getLogPath, log, logger, type LogLevel } from './logger'
|
||||
|
||||
const isDev = !!process.env['ELECTRON_RENDERER_URL']
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
// Before anything else can fail: start the crash reporter, open the log file and
|
||||
// hook uncaughtException / render-process-gone / preload-error. Everything below
|
||||
// (including a throw at module load) is logged from here on.
|
||||
initDiagnostics(isDev)
|
||||
|
||||
const WATCH_IGNORE = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
])
|
||||
|
||||
let watcher: FSWatcher | null = null
|
||||
let configWatcher: FSWatcher | null = null
|
||||
let gitWatcher: FSWatcher | null = null
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let gitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function broadcast(channel: string): void {
|
||||
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Each Helder window is one project, and one project owns global main-process
|
||||
* state (root + FS/git/config watchers + PTYs). To run several projects beside
|
||||
* each other we therefore spawn a *separate, detached* Helder process per window
|
||||
* rather than opening a second BrowserWindow in this process.
|
||||
*
|
||||
* Spawning `process.execPath` directly also sidesteps macOS LaunchServices, which
|
||||
* otherwise just re-activates the running instance when the bundle is launched
|
||||
* again from Finder/Dock. `HELDER_PROJECT` points the child at a project (the
|
||||
* child's `resolveInitialRoot` reads it); with no path it lands on the launcher.
|
||||
*/
|
||||
function spawnInstance(projectPath?: string): void {
|
||||
const env = { ...process.env }
|
||||
if (projectPath && safeIsDir(projectPath)) env.HELDER_PROJECT = resolve(projectPath)
|
||||
else delete env.HELDER_PROJECT
|
||||
// Packaged: execPath IS the app, no app path needed. Dev: replay our own argv
|
||||
// (the electron-vite main entry) so the child loads the same app.
|
||||
const args = app.isPackaged ? [] : process.argv.slice(1)
|
||||
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env })
|
||||
child.unref()
|
||||
}
|
||||
|
||||
function safeIsDir(p: string): boolean {
|
||||
try { return statSync(p).isDirectory() } catch { return false }
|
||||
}
|
||||
|
||||
function startConfigWatcher(): void {
|
||||
if (configWatcher) { configWatcher.close(); configWatcher = null }
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
const dir = join(root, '.helder')
|
||||
configWatcher = watch([join(dir, 'config.json'), join(dir, 'theme.css')], { ignoreInitial: true })
|
||||
const reload = (): void => { resolveConfig(root).then(() => broadcast('config:changed')).catch(() => {}) }
|
||||
configWatcher.on('add', reload).on('change', reload).on('unlink', reload)
|
||||
}
|
||||
|
||||
/** Resolve the real git directory for a project root. Normally this is
|
||||
* `<root>/.git`, but for worktrees/submodules `.git` is a file containing
|
||||
* `gitdir: <path>` pointing at the actual directory. */
|
||||
function gitDir(root: string): string {
|
||||
const dot = join(root, '.git')
|
||||
try {
|
||||
if (statSync(dot).isFile()) {
|
||||
const m = readFileSync(dot, 'utf8').trim().match(/^gitdir:\s*(.+)$/)
|
||||
if (m) return resolve(root, m[1].trim())
|
||||
}
|
||||
} catch { /* not a worktree, or .git missing */ }
|
||||
return dot
|
||||
}
|
||||
|
||||
/** The main watcher ignores `.git`, so branch switches / commits / staging
|
||||
* done by external tools (Sublime Merge, the CLI, …) would never refresh the
|
||||
* git column. Watch the few git files that mark those events so the renderer
|
||||
* re-reads status: HEAD (branch switch), index (staging), refs/heads + logs
|
||||
* (commits), MERGE_HEAD (in-progress merge). */
|
||||
function startGitWatcher(): void {
|
||||
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
const dir = gitDir(root)
|
||||
gitWatcher = watch(
|
||||
[
|
||||
join(dir, 'HEAD'),
|
||||
join(dir, 'index'),
|
||||
join(dir, 'refs', 'heads'),
|
||||
join(dir, 'logs', 'HEAD'),
|
||||
join(dir, 'MERGE_HEAD'),
|
||||
],
|
||||
{ ignoreInitial: true },
|
||||
)
|
||||
const ping = (): void => {
|
||||
if (gitTimer) clearTimeout(gitTimer)
|
||||
gitTimer = setTimeout(() => broadcast('project:changed'), 200)
|
||||
}
|
||||
gitWatcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
|
||||
}
|
||||
|
||||
/** Show the folder picker; if a new folder is chosen, switch the project
|
||||
* (config + watchers). Returns whether the project changed. */
|
||||
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
|
||||
const next = await openDialog(win)
|
||||
if (!next) return false
|
||||
await resolveConfig(next)
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
startGitWatcher()
|
||||
syncProjectChrome()
|
||||
return true
|
||||
}
|
||||
|
||||
function buildAppMenu(): Menu {
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? [{ role: 'appMenu' as const }] : []),
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Window',
|
||||
accelerator: 'CmdOrCtrl+Shift+N',
|
||||
click: () => spawnInstance(),
|
||||
},
|
||||
{
|
||||
label: 'Open Folder…',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: (_m, win) => {
|
||||
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
|
||||
openFolderFlow(bw).then((changed) => { if (changed) broadcast('project:changed') })
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
// ⌘R refreshes the three left columns instead of reloading the window:
|
||||
// git status (Col A), the file explorer (Col B), and the open file in the
|
||||
// viewer re-read from disk (Col C). We send a dedicated `view:refresh`
|
||||
// ping — distinct from the disk watchers' `project:changed` — so only an
|
||||
// explicit ⌘R force-reloads the viewer from disk; background watcher pings
|
||||
// keep refreshing git + tree without blowing away the editor buffer.
|
||||
// Reload / Force Reload are intentionally omitted so ⌘R never blows away
|
||||
// app state.
|
||||
{
|
||||
label: 'Refresh',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: (_m, win) => {
|
||||
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
|
||||
bw?.webContents.send('view:refresh')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
// A crash you can't read is a crash you can't fix — keep the log one
|
||||
// click away rather than buried in ~/Library/Logs.
|
||||
{ label: 'Open Log', click: () => openLog() },
|
||||
{ label: 'Reveal Log in Finder', click: () => revealLog() },
|
||||
],
|
||||
},
|
||||
]
|
||||
return Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
/** macOS Dock right-click menu. Sits above the system items (Show All Windows,
|
||||
* Hide, Quit) that macOS appends itself. It mirrors File → New Window so a new
|
||||
* project window is one right-click away, even with no window focused. */
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* right-click tells the two tiles apart.
|
||||
*/
|
||||
function buildDockMenu(): Menu {
|
||||
const name = getRoot() ? getProjectTitle() : ''
|
||||
return Menu.buildFromTemplate([
|
||||
...(name ? [{ label: name, enabled: false }, { type: 'separator' as const }] : []),
|
||||
{ label: 'New Window', click: () => spawnInstance() },
|
||||
])
|
||||
}
|
||||
|
||||
function startWatcher(): void {
|
||||
if (watcher) { watcher.close(); watcher = null }
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
watcher = watch(root, {
|
||||
ignoreInitial: true,
|
||||
ignored: (p: string) => p.split(sep).some((seg) => WATCH_IGNORE.has(seg)),
|
||||
})
|
||||
const ping = (): void => {
|
||||
invalidateSymbols() // a changed file may add or remove a class
|
||||
if (watchTimer) clearTimeout(watchTimer)
|
||||
watchTimer = setTimeout(() => broadcast('project:changed'), 250)
|
||||
}
|
||||
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
|
||||
}
|
||||
|
||||
/**
|
||||
* ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
|
||||
* `catch {}` that showed a generic toast ("Create failed") and dropped the
|
||||
* actual cause, so the log records the channel, its args and the error, then
|
||||
* RETHROWS so the renderer keeps behaving exactly as before.
|
||||
*
|
||||
* Slow calls get a line too: an FS/git handler blocking for seconds is the
|
||||
* symptom that precedes a beachball, and it's invisible otherwise.
|
||||
*/
|
||||
const SLOW_MS = 1000
|
||||
|
||||
function handle(channel: string, fn: (e: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown): void {
|
||||
ipcMain.handle(channel, async (e, ...args) => {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const out = await fn(e, ...(args as never[]))
|
||||
const ms = Date.now() - started
|
||||
if (ms >= SLOW_MS) logger.warn('ipc', `${channel} slow`, { ms, args: previewArgs(args) })
|
||||
return out
|
||||
} catch (err) {
|
||||
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args), ms: Date.now() - started })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Log-safe args: file CONTENT (fs:write) would swamp the log, so cap length. */
|
||||
function previewArgs(args: unknown[]): unknown[] {
|
||||
return args.map((a) => (typeof a === 'string' && a.length > 120 ? `${a.slice(0, 120)}… (${a.length} chars)` : a))
|
||||
}
|
||||
|
||||
/** Same, for the fire-and-forget `ipcMain.on` channels. */
|
||||
function on(channel: string, fn: (e: Electron.IpcMainEvent, ...args: never[]) => void): void {
|
||||
ipcMain.on(channel, (e, ...args) => {
|
||||
try {
|
||||
fn(e, ...(args as never[]))
|
||||
} catch (err) {
|
||||
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||||
handle('project:open', async (e) => {
|
||||
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
|
||||
return { root: getRoot(), name: getName() }
|
||||
})
|
||||
handle('projects:recent', () => getRecentProjects())
|
||||
handle('project:openPath', async (_e, path: string) => {
|
||||
setRoot(path)
|
||||
const r = getRoot()
|
||||
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
|
||||
syncProjectChrome()
|
||||
broadcast('project:changed')
|
||||
return { root: getRoot(), name: getName() }
|
||||
})
|
||||
|
||||
handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
|
||||
handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
||||
handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
|
||||
handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
|
||||
handle('fs:imageDataUrl', (_e, rel: string) => { const r = getRoot(); return r ? readImageDataUrl(r, rel) : '' })
|
||||
handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
|
||||
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
|
||||
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
|
||||
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
|
||||
handle('fs:rename', (_e, rel: string, name: string) => { const r = getRoot(); return r ? renameProjectEntry(r, rel, name) : rel })
|
||||
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
|
||||
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
|
||||
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })
|
||||
|
||||
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
||||
|
||||
handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||
handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
|
||||
handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
|
||||
handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
|
||||
handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } })
|
||||
handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
|
||||
|
||||
handle('pty:available', () => ptyAvailable())
|
||||
handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
|
||||
on('pty:write', (_e, id: number, data: string) => writePty(id, data))
|
||||
on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
|
||||
on('pty:kill', (_e, id: number) => killPty(id))
|
||||
|
||||
handle('config:get', () => getConfig())
|
||||
handle('config:theme', () => getThemeCss())
|
||||
|
||||
handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
|
||||
handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
|
||||
|
||||
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||||
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||||
|
||||
// PHP symbols: the declared-name list feeds the ⌘-click underline, the lookup
|
||||
// answers one click. Both build the index on demand — never at window open.
|
||||
handle('symbols:names', () => { const r = getRoot(); return r ? symbolNames(r) : [] })
|
||||
handle('symbols:lookup', (_e, name: string) => {
|
||||
const r = getRoot()
|
||||
return r ? lookupSymbol(r, name) : { name, defs: [], refs: [], refCount: 0 }
|
||||
})
|
||||
|
||||
// The renderer's window into the same log file (see renderer/src/log.ts): its
|
||||
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
|
||||
// main-process and renderer failures interleave in ONE chronological file.
|
||||
on('log:write', (_e, level: LogLevel, scope: string, msg: string, ctx?: unknown) => {
|
||||
log(level, scope, msg, ctx)
|
||||
})
|
||||
handle('log:path', () => getLogPath())
|
||||
handle('log:open', () => openLog())
|
||||
handle('log:reveal', () => revealLog())
|
||||
|
||||
handle('dialog:unsavedClose', async (e, path: string) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
buttons: ['Save', "Don't Save", 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
message: `Save changes to ${path}?`,
|
||||
detail: 'Your changes will be lost if you don’t save them.',
|
||||
}
|
||||
const { response } = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts)
|
||||
return response === 0 ? 'save' : response === 1 ? 'discard' : 'cancel'
|
||||
})
|
||||
}
|
||||
|
||||
/** Window title, app name and Dock menu all carry the open project's name. The
|
||||
* menu is rebuilt because the appMenu role items (About, Hide, Quit) read
|
||||
* `app.name` once, at build time. */
|
||||
function syncProjectChrome(): void {
|
||||
const title = getProjectTitle()
|
||||
app.setName(title)
|
||||
Menu.setApplicationMenu(buildAppMenu())
|
||||
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
|
||||
app.dock?.setMenu(buildDockMenu())
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const win = new BrowserWindow({
|
||||
width: 1680,
|
||||
height: 1040,
|
||||
minWidth: 1100,
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
backgroundColor: '#16171a',
|
||||
title: getProjectTitle(),
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
|
||||
win.on('page-title-updated', (e) => e.preventDefault())
|
||||
win.on('ready-to-show', () => win.show())
|
||||
|
||||
// macOS hides the traffic lights in fullscreen, so the title bar can drop the
|
||||
// 82px it reserves for them. Only the main process knows this state, hence IPC.
|
||||
function sendFullscreen(): void {
|
||||
if (win.isDestroyed()) return
|
||||
win.webContents.send('window:fullscreen', win.isFullScreen())
|
||||
}
|
||||
win.on('enter-full-screen', sendFullscreen)
|
||||
win.on('leave-full-screen', sendFullscreen)
|
||||
win.webContents.on('did-finish-load', sendFullscreen)
|
||||
|
||||
watchWindow(win)
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL(process.env['ELECTRON_RENDERER_URL'] as string)
|
||||
} else {
|
||||
win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// app.setName already ran in initDiagnostics — it has to happen before
|
||||
// app.getPath('logs') resolves, or the log lands in ~/Library/Logs/Electron.
|
||||
Menu.setApplicationMenu(buildAppMenu())
|
||||
// app.dock exists on macOS only.
|
||||
app.dock?.setMenu(buildDockMenu())
|
||||
registerIpc()
|
||||
const initialRoot = getRoot()
|
||||
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
|
||||
if (initialRoot) {
|
||||
await resolveConfig(initialRoot)
|
||||
syncProjectChrome()
|
||||
await addRecentProject(initialRoot)
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
startGitWatcher()
|
||||
}
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
}).catch((e) => {
|
||||
// A throw in startup (bad config, unreadable project) otherwise leaves a
|
||||
// window-less app with no message at all.
|
||||
logger.error('session', 'startup failed', e)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (watcher) { watcher.close(); watcher = null }
|
||||
if (configWatcher) { configWatcher.close(); configWatcher = null }
|
||||
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
|
||||
killAllPtys()
|
||||
if (!isMac) app.quit()
|
||||
})
|
||||
|
||||
app.on('before-quit', () => killAllPtys())
|
||||
169
src/main/logger.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* The app's one log sink: a plain-text file under the OS log dir, written
|
||||
* SYNCHRONOUSLY so a line survives the process dying moments later.
|
||||
*
|
||||
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
|
||||
* process per project window, and every window past the first is spawned by
|
||||
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
|
||||
* from Finder there's no terminal attached either. Before this, a crash left
|
||||
* literally no trace; that's what made "it crashed sometimes" undebuggable.
|
||||
*
|
||||
* This module deliberately does NOT import electron, so it stays unit-testable.
|
||||
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
|
||||
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
|
||||
const MAX_BYTES = 2 * 1024 * 1024
|
||||
const KEEP = 3
|
||||
|
||||
interface LoggerState {
|
||||
file: string | null
|
||||
dir: string | null
|
||||
min: number
|
||||
mirror: boolean
|
||||
/** Byte size tracked in-process so the common path avoids a stat() per line. */
|
||||
size: number
|
||||
}
|
||||
|
||||
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
|
||||
|
||||
/** Absolute path of the active log file, or null before initLogger(). */
|
||||
export function getLogPath(): string | null {
|
||||
return state.file
|
||||
}
|
||||
|
||||
export function getLogDir(): string | null {
|
||||
return state.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the logger at `dir` (created if needed). Safe to call once per process.
|
||||
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
|
||||
* terminal IS attached. `level` gates the floor (default: everything).
|
||||
*/
|
||||
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
|
||||
state.dir = opts.dir
|
||||
state.file = join(opts.dir, 'helder.log')
|
||||
state.mirror = !!opts.mirror
|
||||
state.min = LEVELS[opts.level ?? 'debug']
|
||||
try {
|
||||
mkdirSync(opts.dir, { recursive: true })
|
||||
state.size = statSync(state.file).size
|
||||
} catch {
|
||||
// Missing file is the normal first-run case (size stays 0). A genuinely
|
||||
// unwritable dir surfaces on the first write() instead, which no-ops.
|
||||
state.size = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
|
||||
* file crosses MAX_BYTES. Several project processes share one file and could in
|
||||
* principle rotate at the same moment; the renames are best-effort and a lost
|
||||
* race costs at most some log lines, never a crash — hence the blanket catch.
|
||||
*/
|
||||
function rotate(): void {
|
||||
const dir = state.dir
|
||||
const file = state.file
|
||||
if (!dir || !file) return
|
||||
try {
|
||||
const oldest = join(dir, `helder.${KEEP}.log`)
|
||||
try { unlinkSync(oldest) } catch { /* wasn't there */ }
|
||||
for (let i = KEEP - 1; i >= 1; i--) {
|
||||
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
|
||||
}
|
||||
renameSync(file, join(dir, 'helder.1.log'))
|
||||
state.size = 0
|
||||
} catch { /* another process rotated first; keep appending */ }
|
||||
}
|
||||
|
||||
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
|
||||
function safeJson(value: unknown): string {
|
||||
const seen = new WeakSet<object>()
|
||||
try {
|
||||
return JSON.stringify(value, (_k, v) => {
|
||||
if (typeof v === 'bigint') return `${v}n`
|
||||
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[Circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
return v
|
||||
}) ?? String(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
|
||||
* (people throw strings), and `cause` is followed so wrapped errors keep their
|
||||
* root cause — usually the line that actually explains the failure.
|
||||
*/
|
||||
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
|
||||
if (e instanceof Error) {
|
||||
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
|
||||
if (e.stack) out.stack = e.stack
|
||||
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
|
||||
return out
|
||||
}
|
||||
return { message: typeof e === 'string' ? e : safeJson(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One log line: ISO ts · level · pid · scope · message · context JSON.
|
||||
*
|
||||
* Continuation lines are indented, never bare: a message can carry newlines of
|
||||
* its own (Electron's console warnings do, and so does any stack passed as the
|
||||
* message), and an unindented second line is indistinguishable from a new entry
|
||||
* to both a human skimming the file and to `grep`.
|
||||
*/
|
||||
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
|
||||
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
|
||||
if (ctx === undefined) return head + '\n'
|
||||
return head + ' ' + indent(safeJson(ctx)) + '\n'
|
||||
}
|
||||
|
||||
function indent(s: string): string {
|
||||
return s.replace(/\r?\n/g, '\n ')
|
||||
}
|
||||
|
||||
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
|
||||
if (LEVELS[level] < state.min) return
|
||||
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
|
||||
|
||||
if (state.mirror) {
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||
fn(line.trimEnd())
|
||||
}
|
||||
|
||||
const file = state.file
|
||||
if (!file) return
|
||||
if (state.size + line.length > MAX_BYTES) rotate()
|
||||
try {
|
||||
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
|
||||
// concurrent appends from sibling project processes don't interleave.
|
||||
appendFileSync(file, line, { encoding: 'utf8' })
|
||||
state.size += Buffer.byteLength(line)
|
||||
} catch { /* disk full / no permission — never let logging break the app */ }
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
|
||||
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
|
||||
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
|
||||
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
|
||||
}
|
||||
|
||||
/** Reset for tests. Not used by the app. */
|
||||
export function _resetLogger(): void {
|
||||
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
|
||||
}
|
||||
28
src/main/notes-service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Project scratch note: a plain text file at `<project>/.notes.txt`.
|
||||
*
|
||||
* Deliberately not JSON and not part of `.helder/`. It is a note the user
|
||||
* writes by hand, so it must stay readable and editable outside Helder.
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from './logger'
|
||||
|
||||
export const NOTES_FILE = '.notes.txt'
|
||||
|
||||
/** The note's text. Empty string when the project has no note yet. */
|
||||
export async function readNote(root: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(join(root, NOTES_FILE), 'utf8')
|
||||
} catch (err) {
|
||||
// ENOENT is the normal "no note yet" case, anything else is worth knowing.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('notes', 'read failed', { root, err: String(err) })
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeNote(root: string, text: string): Promise<void> {
|
||||
await writeFile(join(root, NOTES_FILE), text, 'utf8')
|
||||
}
|
||||
111
src/main/project.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
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
|
||||
* a directory passed on argv; otherwise it starts as `null` — the app then shows
|
||||
* the project launcher (Spotlight / bare launch with no folder). It can be set at
|
||||
* runtime from the launcher or the Open Folder dialog.
|
||||
*
|
||||
* The bare "." that `electron .` passes in dev is intentionally ignored, so dev
|
||||
* launches land on the launcher too (and `basename(".")` never leaks as a name).
|
||||
*/
|
||||
function resolveInitialRoot(): string | null {
|
||||
const envRoot = process.env.HELDER_PROJECT
|
||||
if (envRoot && safeIsDir(envRoot)) return resolve(envRoot)
|
||||
const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a))
|
||||
if (argDir) return resolve(argDir)
|
||||
return null
|
||||
}
|
||||
|
||||
function safeIsDir(p: string): boolean {
|
||||
try { return statSync(p).isDirectory() } catch { return false }
|
||||
}
|
||||
|
||||
let root: string | null = resolveInitialRoot()
|
||||
|
||||
export function getRoot(): string | null {
|
||||
return root
|
||||
}
|
||||
|
||||
export function getName(): string {
|
||||
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. */
|
||||
export function setRoot(next: string): void {
|
||||
root = resolve(next)
|
||||
addRecentProject(root).catch(() => {})
|
||||
}
|
||||
|
||||
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
||||
const res = win
|
||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
if (!res.canceled && res.filePaths[0]) {
|
||||
setRoot(res.filePaths[0])
|
||||
return root
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- recent projects (global, machine-wide — stored in Electron userData) ------
|
||||
export interface RecentProject { path: string; name: string }
|
||||
const MAX_PROJECTS = 20
|
||||
|
||||
function projectsFile(): string {
|
||||
return join(app.getPath('userData'), 'recent-projects.json')
|
||||
}
|
||||
|
||||
export async function getRecentProjects(): Promise<RecentProject[]> {
|
||||
try {
|
||||
const arr = JSON.parse(await readFile(projectsFile(), 'utf8'))
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr
|
||||
.filter((p): p is RecentProject => !!p && typeof p.path === 'string')
|
||||
.filter((p) => existsSync(p.path)) // drop projects that no longer exist
|
||||
.slice(0, MAX_PROJECTS)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRecentProject(path: string): Promise<void> {
|
||||
try {
|
||||
const abs = resolve(path)
|
||||
const list = await getRecentProjects()
|
||||
const entry: RecentProject = { path: abs, name: basename(abs) || abs }
|
||||
const next = [entry, ...list.filter((x) => x.path !== abs)].slice(0, MAX_PROJECTS)
|
||||
await writeFile(projectsFile(), JSON.stringify(next, null, 2) + '\n')
|
||||
} catch {
|
||||
/* userData unwritable — recents just won't persist */
|
||||
}
|
||||
}
|
||||
136
src/main/pty-service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import type { WebContents } from 'electron'
|
||||
import { getRoot } from './project'
|
||||
import { getConfig } from './config'
|
||||
import { logger } from './logger'
|
||||
|
||||
/**
|
||||
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
|
||||
* bottom pane is a plain shell. node-pty is a native module — loaded defensively
|
||||
* so the app still launches (with a friendly message) if it wasn't rebuilt for
|
||||
* this Electron via `npm run rebuild`.
|
||||
*
|
||||
* Shell + ai command/autoLaunch come from `.helder/config.json` (terminal.shell,
|
||||
* ai.command, ai.autoLaunch) via the config module.
|
||||
*/
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
type PtyModule = typeof import('node-pty')
|
||||
let pty: PtyModule | null = null
|
||||
try {
|
||||
pty = require('node-pty') as PtyModule
|
||||
} catch (e) {
|
||||
logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
|
||||
}
|
||||
|
||||
const terms = new Map<number, import('node-pty').IPty>()
|
||||
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
|
||||
* Their exit is expected, so it must NOT be logged as a warning — a log full of
|
||||
* false alarms is a log nobody reads. */
|
||||
const killing = new Set<number>()
|
||||
let seq = 0
|
||||
|
||||
/**
|
||||
* Env for spawned PTYs. Electron launched from a Homebrew/GUI context leaks
|
||||
* `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes
|
||||
* nvm refuse to load ("nvm is not compatible with the npm_config_prefix
|
||||
* environment variable"). Strip it so the user's shell init runs cleanly.
|
||||
*
|
||||
* A macOS app launched from Spotlight/Finder (launchd GUI context) inherits NO
|
||||
* `LANG`/`LC_*`, so the child shell falls back to the `C`/POSIX locale — not
|
||||
* UTF-8. Anything multibyte the shell or `claude` emits then renders as high-byte
|
||||
* mojibake in xterm. Launching from a terminal (`npm run dev`) inherits the
|
||||
* terminal's UTF-8 locale, which is why dev looks fine and the packaged app does
|
||||
* not. Default a UTF-8 locale when none is set so both paths match.
|
||||
*/
|
||||
function ptyEnv(): { [key: string]: string } {
|
||||
const env = { ...process.env } as { [key: string]: string }
|
||||
delete env.npm_config_prefix
|
||||
delete env.npm_config_globalconfig
|
||||
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
|
||||
env.LANG = 'en_US.UTF-8'
|
||||
}
|
||||
// Same inheritance gap as locale, but for color: a terminal launch leaks
|
||||
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
|
||||
// colors; the GUI-launched packaged app has none, so claude falls back to a
|
||||
// 256/16-color approximation and the same backgrounds shift shade. Match both.
|
||||
if (process.platform !== 'win32' && !env.COLORTERM) {
|
||||
env.COLORTERM = 'truecolor'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
const configured = getConfig().terminal.shell
|
||||
if (configured) return configured
|
||||
if (process.platform === 'win32') return process.env.COMSPEC || 'powershell.exe'
|
||||
return process.env.SHELL || '/bin/zsh'
|
||||
}
|
||||
|
||||
export function ptyAvailable(): boolean {
|
||||
return !!pty
|
||||
}
|
||||
|
||||
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
|
||||
if (!pty) {
|
||||
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
|
||||
return -1
|
||||
}
|
||||
const cwd = getRoot() || process.env.HOME || process.cwd()
|
||||
const shell = defaultShell()
|
||||
const ai = getConfig().ai
|
||||
const launchAgent = kind === 'agent' && ai.autoLaunch && process.platform !== 'win32'
|
||||
// For the agent pane we exec the `claude` CLI directly as the shell's command
|
||||
// (`zsh -i -c 'claude'`) instead of typing it into an interactive prompt — `-i`
|
||||
// still sources the user's rc (nvm etc.), but there's no prompt line and no
|
||||
// echoed command cluttering the pane; claude takes over a clean terminal.
|
||||
const args = launchAgent ? ['-i', '-c', ai.command] : []
|
||||
const proc = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
cwd,
|
||||
env: ptyEnv(),
|
||||
})
|
||||
const id = ++seq
|
||||
terms.set(id, proc)
|
||||
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
|
||||
|
||||
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
|
||||
proc.onExit(({ exitCode, signal }) => {
|
||||
terms.delete(id)
|
||||
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
|
||||
// segfault) looks from the UI like "the terminal just went blank" — the exit
|
||||
// code and signal are the only evidence of what actually happened. An exit we
|
||||
// asked for is routine, so only an unrequested one is a warning.
|
||||
const expected = killing.delete(id)
|
||||
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
|
||||
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
|
||||
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
|
||||
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
|
||||
})
|
||||
|
||||
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
|
||||
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
|
||||
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function writePty(id: number, data: string): void {
|
||||
terms.get(id)?.write(data)
|
||||
}
|
||||
|
||||
export function resizePty(id: number, cols: number, rows: number): void {
|
||||
try { terms.get(id)?.resize(cols, rows) } catch { /* race with exit */ }
|
||||
}
|
||||
|
||||
export function killPty(id: number): void {
|
||||
const p = terms.get(id)
|
||||
if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
|
||||
}
|
||||
|
||||
export function killAllPtys(): void {
|
||||
for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
|
||||
terms.clear()
|
||||
}
|
||||
120
src/main/search-service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { relative, sep } from 'node:path'
|
||||
import { getConfig } from './config'
|
||||
|
||||
/** ripgrep is the single source of "what files are in the project": it powers
|
||||
* content search, the file-name list, AND the Explorer tree / content index
|
||||
* (via fs-service) — so gitignore + files.exclude are honored everywhere the
|
||||
* same way. Substring (fixed-string), smart-case search.
|
||||
*
|
||||
* @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for
|
||||
* ESM and CJS) and cache the resolved binary path. */
|
||||
const rgPathPromise: Promise<string | null> = (async () => {
|
||||
try {
|
||||
const mod = await import('@vscode/ripgrep')
|
||||
let p = (mod as { rgPath: string }).rgPath
|
||||
// When packaged the binary is unpacked from the asar; rgPath still points
|
||||
// inside app.asar, so redirect it. No-op in dev (path has no app.asar).
|
||||
if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
|
||||
return p || null
|
||||
} catch (e) {
|
||||
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
export interface ContentHit { no: number; ln: string; ix: number }
|
||||
export interface ContentGroup { path: string; hits: ContentHit[] }
|
||||
|
||||
/** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */
|
||||
const BASE_IGNORE = [
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
|
||||
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
|
||||
]
|
||||
|
||||
const MAX_FILES = 400
|
||||
const MAX_LINE = 1000
|
||||
|
||||
export async function rgAvailable(): Promise<boolean> {
|
||||
return !!(await rgPathPromise)
|
||||
}
|
||||
|
||||
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
|
||||
function ignoreArgs(): string[] {
|
||||
const cfg = getConfig()
|
||||
const args: string[] = []
|
||||
for (const d of BASE_IGNORE) args.push('--glob', `!${d}`)
|
||||
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
|
||||
if (!cfg.files.followGitignore) args.push('--no-ignore')
|
||||
return args
|
||||
}
|
||||
|
||||
function toRel(root: string, p: string): string {
|
||||
return relative(root, p).split(sep).join('/')
|
||||
}
|
||||
|
||||
export async function searchContent(root: string, query: string): Promise<ContentGroup[]> {
|
||||
const rgPath = await rgPathPromise
|
||||
if (!rgPath || query.trim().length < 2) return []
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(rgPath, [
|
||||
'--json', '--fixed-strings', '--smart-case', '--hidden',
|
||||
'--max-count', '50', '--max-columns', '2000',
|
||||
...ignoreArgs(), '-e', query, '--', root,
|
||||
])
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, ContentGroup>()
|
||||
let buf = ''
|
||||
let done = false
|
||||
const finish = (): void => { if (done) return; done = true; resolve(order.slice(0, MAX_FILES).map((p) => groups.get(p)!)) }
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
||||
if (!line) continue
|
||||
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
|
||||
try { msg = JSON.parse(line) } catch { continue }
|
||||
if (msg.type !== 'match') continue
|
||||
const abs = msg.data.path?.text
|
||||
const text = msg.data.lines?.text
|
||||
if (!abs || text == null) continue
|
||||
const rel = toRel(root, abs)
|
||||
let g = groups.get(rel)
|
||||
if (!g) { if (groups.size >= MAX_FILES) continue; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
|
||||
const ln = text.replace(/\n$/, '').slice(0, MAX_LINE)
|
||||
const ix = msg.data.submatches && msg.data.submatches[0] ? msg.data.submatches[0].start : 0
|
||||
g.hits.push({ no: msg.data.line_number || 0, ln, ix: Math.min(ix, ln.length) })
|
||||
}
|
||||
})
|
||||
child.on('close', finish)
|
||||
child.on('error', finish)
|
||||
})
|
||||
}
|
||||
|
||||
/** All project files (relative paths), honoring gitignore + excludes. Includes
|
||||
* dotfiles (--hidden) so .env etc. show up unless ignored. Pass `scope` (an
|
||||
* absolute dir inside root) to list only that subtree; paths stay relative to
|
||||
* root either way. */
|
||||
export async function listFiles(root: string, scope?: string): Promise<string[]> {
|
||||
const rgPath = await rgPathPromise
|
||||
if (!rgPath) return []
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
|
||||
let buf = ''
|
||||
const out: string[] = []
|
||||
let done = false
|
||||
const finish = (): void => { if (done) return; done = true; resolve(out) }
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
||||
if (line) out.push(toRel(root, line))
|
||||
}
|
||||
})
|
||||
child.on('close', () => { if (buf.trim()) out.push(toRel(root, buf.trim())); finish() })
|
||||
child.on('error', finish)
|
||||
})
|
||||
}
|
||||
262
src/main/symbols-service.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/* PHP symbol index + reference lookup.
|
||||
*
|
||||
* Two jobs, both on ripgrep:
|
||||
* - the index: every class/interface/trait/enum DECLARED in the project AND in
|
||||
* vendor (so a framework class is clickable too), as
|
||||
* name → {path, line}. The renderer needs the bare name list to decide which
|
||||
* tokens it may underline, and that decision is per visible token per frame,
|
||||
* so it cannot be a search. One rg pass answers it for every token at once.
|
||||
* - the lookup: on ⌘-click, the declarations of one name plus every reference
|
||||
* to it, found there and then. Nothing about usages is cached.
|
||||
*
|
||||
* The index is built lazily and never on the startup path: the first caller
|
||||
* starts it and later callers join the same promise. A file change drops it, so
|
||||
* the next caller pays for the rebuild (~100 ms) instead of the window opening.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { relative, sep } from 'node:path'
|
||||
import { getConfig } from './config'
|
||||
import { log } from './logger'
|
||||
|
||||
export interface SymbolDef {
|
||||
name: string; path: string; line: number; kind: string
|
||||
/** The file's `namespace`, so the popup can name the class in full. */
|
||||
ns: string
|
||||
/** Parents named on the declaration line: `extends A`, `implements B, C`. */
|
||||
parents: string[]
|
||||
interfaces: string[]
|
||||
/** Traits mixed in by an indented `use A, B;` inside this declaration's body. */
|
||||
traits: string[]
|
||||
}
|
||||
export interface RefHit { no: number; ln: string; ix: number }
|
||||
export interface RefGroup { path: string; hits: RefHit[] }
|
||||
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }
|
||||
|
||||
/** Same resolution dance as search-service: @vscode/ripgrep is ESM, and the
|
||||
* packaged binary lives outside the asar. */
|
||||
const rgPathPromise: Promise<string | null> = (async () => {
|
||||
try {
|
||||
const mod = await import('@vscode/ripgrep')
|
||||
let p = (mod as { rgPath: string }).rgPath
|
||||
if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
|
||||
return p || null
|
||||
} catch (e) {
|
||||
log('error', 'symbols', 'ripgrep unavailable', (e as Error).message)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const BASE_IGNORE = [
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
|
||||
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
|
||||
]
|
||||
/** `vendor/` is out of the reference search — nobody wants to read how Symfony
|
||||
* uses Throwable — but it IS indexed for declarations, so a framework class is
|
||||
* still a name we can underline and open. Measured on a Laravel app: 48 project
|
||||
* classes in 30 ms, 7k more with vendor in 170 ms. */
|
||||
const DECL_IGNORE = BASE_IGNORE.filter((d) => d !== 'vendor')
|
||||
|
||||
/** Declarations only. `readonly`/`final`/`abstract` may precede the keyword, and
|
||||
* an enum may carry a backing type. Anchored at the line start (with optional
|
||||
* indent) so `$x instanceof class` shaped text cannot match. */
|
||||
const DECL_RE = /^[ \t]*(?:(?:final|abstract|readonly)[ \t]+)*(class|interface|trait|enum)[ \t]+([A-Za-z_]\w*)(.*)$/
|
||||
/** Declarations and the file's namespace in one pass: ripgrep emits a file's
|
||||
* matches together and in line order, and `namespace` always precedes the
|
||||
* declarations it covers, so the last one seen is the right one. */
|
||||
const DECL_RG = '^\\s*(namespace\\s+[A-Za-z_\\\\][\\w\\\\]*\\s*;|(final\\s+|abstract\\s+|readonly\\s+)*(class|interface|trait|enum)\\s+\\w+)'
|
||||
const NS_RE = /^[ \t]*namespace[ \t]+([A-Za-z_\\][\w\\]*)[ \t]*;/
|
||||
/** An indented `use` — a trait mixed into a class body, not a namespace import. */
|
||||
const TRAIT_RE = /^[ \t]+use[ \t]+([A-Za-z_\\][\w\\ \t,]*?)[ \t]*[;{]/
|
||||
const TRAIT_RG = '^\\s+use\\s+[A-Za-z_\\\\][\\w\\\\ \t,]*[;{]'
|
||||
|
||||
const MAX_DEFS = 25
|
||||
const MAX_REF_FILES = 300
|
||||
const MAX_LINE = 1000
|
||||
|
||||
function ignoreArgs(dirs: string[] = BASE_IGNORE): string[] {
|
||||
const cfg = getConfig()
|
||||
const args: string[] = []
|
||||
for (const d of dirs) args.push('--glob', `!${d}`)
|
||||
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
|
||||
if (!cfg.files.followGitignore) args.push('--no-ignore')
|
||||
return args
|
||||
}
|
||||
|
||||
function toRel(root: string, p: string): string {
|
||||
return relative(root, p).split(sep).join('/')
|
||||
}
|
||||
|
||||
/** Bare class names out of an `extends`/`implements` clause: split on commas and
|
||||
* drop the namespace, since the index is keyed on the short name. */
|
||||
function clauseNames(clause: string): string[] {
|
||||
return clause.split(',')
|
||||
.map((part) => (part.trim().split('\\').pop() ?? '').trim())
|
||||
.filter((n) => /^[A-Za-z_]\w*$/.test(n))
|
||||
}
|
||||
|
||||
/** The namespace a `namespace X;` line declares, or null. */
|
||||
export function parseNamespace(text: string): string | null {
|
||||
const m = NS_RE.exec(text)
|
||||
return m ? m[1].replace(/^\\/, '') : null
|
||||
}
|
||||
|
||||
/** The traits an indented `use` line mixes in, or [] for anything else. A
|
||||
* `use function …` or a closure's `use ($x)` is not a trait. */
|
||||
export function parseTraitUse(text: string): string[] {
|
||||
const m = TRAIT_RE.exec(text)
|
||||
if (!m || /^\s*use\s+(function|const)\b/.test(text)) return []
|
||||
return clauseNames(m[1])
|
||||
}
|
||||
|
||||
/** The declaration on one line, or null. Exported for the unit tests.
|
||||
* Only this line is read, so an `implements` list wrapped onto the next line is
|
||||
* not seen — the popup then shows fewer parents, never wrong ones. */
|
||||
export function parseDeclaration(text: string): { kind: string; name: string; parents: string[]; interfaces: string[] } | null {
|
||||
const m = DECL_RE.exec(text)
|
||||
if (!m) return null
|
||||
const rest = m[3] ?? ''
|
||||
const ext = /\bextends\s+([^{]*?)(?:\s+implements\b|\s*\{|$)/.exec(rest)
|
||||
const impl = /\bimplements\s+([^{]*?)(?:\s*\{|$)/.exec(rest)
|
||||
return {
|
||||
kind: m[1],
|
||||
name: m[2],
|
||||
parents: ext ? clauseNames(ext[1]) : [],
|
||||
interfaces: impl ? clauseNames(impl[1]) : [],
|
||||
}
|
||||
}
|
||||
|
||||
/** A namespace import — `use App\Models\Agent;` at column 0, with an optional
|
||||
* alias or a group body. A `use` INSIDE a class body is indented and mixes a
|
||||
* trait in, which is a real reference, so the indent is what separates them. */
|
||||
export function isImportLine(text: string): boolean {
|
||||
return /^use[ \t]+[^;]*;?[ \t\r]*$/.test(text)
|
||||
}
|
||||
|
||||
/** Run rg with --json and hand every match line to `onMatch`. */
|
||||
function rgJson(args: string[], onMatch: (path: string, line: number, text: string, col: number) => void): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
rgPathPromise.then((rgPath) => {
|
||||
if (!rgPath) { resolve(); return }
|
||||
const child = spawn(rgPath, args)
|
||||
let buf = ''
|
||||
let done = false
|
||||
const finish = (): void => { if (done) return; done = true; resolve() }
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
buf += chunk.toString()
|
||||
let nl: number
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl); buf = buf.slice(nl + 1)
|
||||
if (!line) continue
|
||||
let msg: { type: string; data: { path?: { text?: string }; lines?: { text?: string }; line_number?: number; submatches?: { start: number }[] } }
|
||||
try { msg = JSON.parse(line) } catch { continue }
|
||||
if (msg.type !== 'match') continue
|
||||
const abs = msg.data.path?.text
|
||||
const text = msg.data.lines?.text
|
||||
if (!abs || text == null) continue
|
||||
onMatch(abs, msg.data.line_number || 0, text.replace(/\n$/, ''), msg.data.submatches?.[0]?.start ?? 0)
|
||||
}
|
||||
})
|
||||
child.on('close', finish)
|
||||
child.on('error', (e) => { log('error', 'symbols', 'ripgrep failed', (e as Error).message); finish() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface Index { root: string; byName: Map<string, SymbolDef[]> }
|
||||
let index: Index | null = null
|
||||
let building: Promise<Index> | null = null
|
||||
|
||||
async function build(root: string): Promise<Index> {
|
||||
const t0 = Date.now()
|
||||
const byName = new Map<string, SymbolDef[]>()
|
||||
const byFile = new Map<string, SymbolDef[]>()
|
||||
const nsByFile = new Map<string, string>()
|
||||
await rgJson(
|
||||
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', DECL_RG, '--', root],
|
||||
(abs, line, text) => {
|
||||
const rel = toRel(root, abs)
|
||||
const ns = parseNamespace(text)
|
||||
if (ns != null) { nsByFile.set(rel, ns); return }
|
||||
const d = parseDeclaration(text)
|
||||
if (!d) return
|
||||
const def: SymbolDef = {
|
||||
name: d.name, path: rel, line, kind: d.kind, ns: nsByFile.get(rel) ?? '',
|
||||
parents: d.parents, interfaces: d.interfaces, traits: [],
|
||||
}
|
||||
const list = byName.get(d.name)
|
||||
if (list) list.push(def)
|
||||
else byName.set(d.name, [def])
|
||||
const inFile = byFile.get(def.path)
|
||||
if (inFile) inFile.push(def)
|
||||
else byFile.set(def.path, [def])
|
||||
},
|
||||
)
|
||||
// Traits live in the body, not on the declaration line, so they need their own
|
||||
// pass. A `use` belongs to the last declaration above it in the same file —
|
||||
// which is also why the declaration pass has to run first.
|
||||
await rgJson(
|
||||
['--json', '--hidden', '--glob', '*.php', ...ignoreArgs(DECL_IGNORE), '-e', TRAIT_RG, '--', root],
|
||||
(abs, line, text) => {
|
||||
const names = parseTraitUse(text)
|
||||
if (!names.length) return
|
||||
const decls = byFile.get(toRel(root, abs))
|
||||
if (!decls) return
|
||||
let owner: SymbolDef | null = null
|
||||
for (const d of decls) if (d.line < line && (!owner || d.line > owner.line)) owner = d
|
||||
if (owner) for (const n of names) if (!owner.traits.includes(n)) owner.traits.push(n)
|
||||
},
|
||||
)
|
||||
log('info', 'symbols', 'index built', { classes: byName.size, ms: Date.now() - t0 })
|
||||
return { root, byName }
|
||||
}
|
||||
|
||||
/** The index for `root`, built on first use. Concurrent callers share one build. */
|
||||
function getIndex(root: string): Promise<Index> {
|
||||
if (index && index.root === root) return Promise.resolve(index)
|
||||
if (building) return building
|
||||
building = build(root).then((ix) => { index = ix; building = null; return ix })
|
||||
.catch((e) => { building = null; throw e })
|
||||
return building
|
||||
}
|
||||
|
||||
/** Drop the index after a file change. The next caller rebuilds it. */
|
||||
export function invalidateSymbols(): void {
|
||||
index = null
|
||||
}
|
||||
|
||||
/** Every declared name. The renderer keeps this as a Set to mark tokens. */
|
||||
export async function symbolNames(root: string): Promise<string[]> {
|
||||
const ix = await getIndex(root)
|
||||
return [...ix.byName.keys()]
|
||||
}
|
||||
|
||||
/** Declarations of `name` plus every reference to it, grouped by file. Namespace
|
||||
* imports are dropped (noise), and so are the declaration lines themselves —
|
||||
* they are already the first section of the popup. */
|
||||
export async function lookupSymbol(root: string, name: string): Promise<SymbolLookup> {
|
||||
if (!/^[A-Za-z_]\w*$/.test(name)) return { name, defs: [], refs: [], refCount: 0 }
|
||||
const ix = await getIndex(root)
|
||||
// The project's own declaration comes first: a common name like `Handler` is
|
||||
// declared a dozen times inside vendor, and none of those is what was clicked.
|
||||
const defs = (ix.byName.get(name) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.path.startsWith('vendor/')) - Number(b.path.startsWith('vendor/')) || a.path.localeCompare(b.path))
|
||||
.slice(0, MAX_DEFS)
|
||||
const declared = new Set(defs.map((d) => d.path + ':' + d.line))
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, RefGroup>()
|
||||
let refCount = 0
|
||||
await rgJson(
|
||||
['--json', '--hidden', '--word-regexp', '--glob', '*.php', ...ignoreArgs(), '-e', name, '--', root],
|
||||
(abs, line, text, col) => {
|
||||
const rel = toRel(root, abs)
|
||||
if (declared.has(rel + ':' + line) || isImportLine(text)) return
|
||||
let g = groups.get(rel)
|
||||
if (!g) { if (groups.size >= MAX_REF_FILES) return; g = { path: rel, hits: [] }; groups.set(rel, g); order.push(rel) }
|
||||
const ln = text.slice(0, MAX_LINE)
|
||||
g.hits.push({ no: line, ln, ix: Math.min(col, ln.length) })
|
||||
refCount++
|
||||
},
|
||||
)
|
||||
return { name, defs, refs: order.map((p) => groups.get(p) as RefGroup), refCount }
|
||||
}
|
||||
9
src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { HelderApi } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
helder: HelderApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
144
src/preload/index.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { contextBridge, clipboard, ipcRenderer } from 'electron'
|
||||
|
||||
/**
|
||||
* The single bridge between renderer and main. The renderer NEVER touches the
|
||||
* filesystem, git or the OS clipboard directly — everything goes through here.
|
||||
* (PTYs land here next, for the terminals.)
|
||||
*/
|
||||
const api = {
|
||||
platform: process.platform,
|
||||
|
||||
clipboard: {
|
||||
writeText: (text: string) => clipboard.writeText(text),
|
||||
readText: (): string => clipboard.readText(),
|
||||
},
|
||||
|
||||
project: {
|
||||
current: () => ipcRenderer.invoke('project:current'),
|
||||
open: () => ipcRenderer.invoke('project:open'),
|
||||
openPath: (path: string) => ipcRenderer.invoke('project:openPath', path),
|
||||
recent: (): Promise<{ path: string; name: string }[]> => ipcRenderer.invoke('projects:recent'),
|
||||
},
|
||||
|
||||
fs: {
|
||||
tree: () => ipcRenderer.invoke('fs:tree'),
|
||||
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
|
||||
files: () => ipcRenderer.invoke('fs:files'),
|
||||
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
||||
imageDataUrl: (path: string): Promise<string> => ipcRenderer.invoke('fs:imageDataUrl', path),
|
||||
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
||||
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
||||
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
|
||||
mkdir: (path: string): Promise<void> => ipcRenderer.invoke('fs:mkdir', path),
|
||||
rename: (path: string, name: string): Promise<string> => ipcRenderer.invoke('fs:rename', path, name),
|
||||
},
|
||||
|
||||
shell: {
|
||||
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
|
||||
},
|
||||
|
||||
notes: {
|
||||
read: (): Promise<string> => ipcRenderer.invoke('notes:read'),
|
||||
write: (text: string): Promise<void> => ipcRenderer.invoke('notes:write', text),
|
||||
},
|
||||
git: {
|
||||
load: () => ipcRenderer.invoke('git:load'),
|
||||
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
||||
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
|
||||
commit: (message: string) => ipcRenderer.invoke('git:commit', message),
|
||||
push: (): Promise<{ ok: boolean; message: string }> => ipcRenderer.invoke('git:push'),
|
||||
discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
|
||||
},
|
||||
|
||||
pty: {
|
||||
available: (): Promise<boolean> => ipcRenderer.invoke('pty:available'),
|
||||
create: (kind: 'agent' | 'shell', cols: number, rows: number): Promise<number> =>
|
||||
ipcRenderer.invoke('pty:create', kind, cols, rows),
|
||||
write: (id: number, data: string): void => ipcRenderer.send('pty:write', id, data),
|
||||
resize: (id: number, cols: number, rows: number): void => ipcRenderer.send('pty:resize', id, cols, rows),
|
||||
kill: (id: number): void => ipcRenderer.send('pty:kill', id),
|
||||
onData: (cb: (id: number, data: string) => void): (() => void) => {
|
||||
const h = (_e: unknown, p: { id: number; data: string }): void => cb(p.id, p.data)
|
||||
ipcRenderer.on('pty:data', h)
|
||||
return () => ipcRenderer.removeListener('pty:data', h)
|
||||
},
|
||||
onExit: (cb: (id: number) => void): (() => void) => {
|
||||
const h = (_e: unknown, p: { id: number }): void => cb(p.id)
|
||||
ipcRenderer.on('pty:exit', h)
|
||||
return () => ipcRenderer.removeListener('pty:exit', h)
|
||||
},
|
||||
},
|
||||
|
||||
config: {
|
||||
get: () => ipcRenderer.invoke('config:get'),
|
||||
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
|
||||
},
|
||||
|
||||
recent: {
|
||||
get: (): Promise<string[]> => ipcRenderer.invoke('recent:get'),
|
||||
set: (list: string[]): Promise<void> => ipcRenderer.invoke('recent:set', list),
|
||||
},
|
||||
|
||||
search: {
|
||||
content: (query: string) => ipcRenderer.invoke('search:content', query),
|
||||
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
||||
},
|
||||
|
||||
symbols: {
|
||||
names: (): Promise<string[]> => ipcRenderer.invoke('symbols:names'),
|
||||
lookup: (name: string) => ipcRenderer.invoke('symbols:lookup', name),
|
||||
},
|
||||
|
||||
dialog: {
|
||||
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
|
||||
ipcRenderer.invoke('dialog:unsavedClose', path),
|
||||
},
|
||||
|
||||
/** Renderer errors → the main process log file (see renderer/src/log.ts).
|
||||
* `write` is fire-and-forget on purpose: logging must never await, and must
|
||||
* never be able to reject into the very handler that's reporting a failure. */
|
||||
log: {
|
||||
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown): void => {
|
||||
ipcRenderer.send('log:write', level, scope, msg, ctx)
|
||||
},
|
||||
path: (): Promise<string | null> => ipcRenderer.invoke('log:path'),
|
||||
open: (): Promise<void> => ipcRenderer.invoke('log:open'),
|
||||
reveal: (): Promise<void> => ipcRenderer.invoke('log:reveal'),
|
||||
},
|
||||
|
||||
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
|
||||
onProjectChanged: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
ipcRenderer.on('project:changed', handler)
|
||||
return () => ipcRenderer.removeListener('project:changed', handler)
|
||||
},
|
||||
|
||||
/** Subscribe to .helder config/theme edits. Returns an unsubscribe. */
|
||||
onConfigChanged: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
ipcRenderer.on('config:changed', handler)
|
||||
return () => ipcRenderer.removeListener('config:changed', handler)
|
||||
},
|
||||
|
||||
/** Subscribe to the window entering/leaving fullscreen. Returns an unsubscribe. */
|
||||
onFullscreen: (cb: (on: boolean) => void): (() => void) => {
|
||||
const handler = (_e: unknown, on: boolean): void => cb(on)
|
||||
ipcRenderer.on('window:fullscreen', handler)
|
||||
return () => ipcRenderer.removeListener('window:fullscreen', handler)
|
||||
},
|
||||
|
||||
/** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */
|
||||
onRefresh: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
ipcRenderer.on('view:refresh', handler)
|
||||
return () => ipcRenderer.removeListener('view:refresh', handler)
|
||||
},
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld('helder', api)
|
||||
} else {
|
||||
;(globalThis as unknown as { helder: typeof api }).helder = api
|
||||
}
|
||||
|
||||
export type HelderApi = typeof api
|
||||
12
src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Helder</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1208
src/renderer/src/App.tsx
Normal file
332
src/renderer/src/components.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/* Shared icons, FileIcon, GitPanel, FileTree */
|
||||
import React, { Fragment } from 'react'
|
||||
import type { Change, DiffSide, FileNode, GitStatus } from './types'
|
||||
import { HL } from './highlight'
|
||||
|
||||
type SvgProps = React.SVGProps<SVGSVGElement>
|
||||
|
||||
/* ---- minimal geometric icons ---- */
|
||||
export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
|
||||
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>),
|
||||
paste: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="3" y="3" width="10" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3V2.2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V3" stroke="currentColor" strokeWidth="1.3" fill="none" /></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>),
|
||||
folder: (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" strokeLinejoin="round" /></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>),
|
||||
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
|
||||
note: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><path d="M3.5 2.5h9v11h-9v-11z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
|
||||
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
|
||||
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
pencil: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2.5 11.2 11 2.7a1.6 1.6 0 0 1 2.3 2.3l-8.5 8.5-3 .7.7-3z" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round" /><path d="M9.6 4.1l2.3 2.3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
|
||||
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
|
||||
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
}
|
||||
|
||||
/** Hover tooltip. One surface, no arrow, no shadow — the current state is named
|
||||
* inside the sentence, in amber, then one sentence saying what a click does. */
|
||||
export function Tip({ text, children }: { text: React.ReactNode; children: React.ReactNode }): React.ReactElement {
|
||||
const [at, setAt] = React.useState<{ x: number; y: number } | null>(null)
|
||||
const ref = React.useRef<HTMLSpanElement>(null)
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
function enter(): void {
|
||||
timer.current = setTimeout(() => {
|
||||
const r = ref.current?.getBoundingClientRect()
|
||||
if (r) setAt({ x: Math.min(r.left, window.innerWidth - 316), y: r.bottom + 8 })
|
||||
}, 350)
|
||||
}
|
||||
function leave(): void {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
setAt(null)
|
||||
}
|
||||
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current) }, [])
|
||||
return (
|
||||
<span className="tip-host" ref={ref} onMouseEnter={enter} onMouseLeave={leave} onMouseDown={leave}>
|
||||
{children}
|
||||
{at && <div className="tip" style={{ left: at.x, top: at.y }}>{text}</div>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
||||
<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>
|
||||
)
|
||||
|
||||
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
|
||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
{open ? (
|
||||
<Fragment>
|
||||
<path d="M2 12.5V4.5h3.6l1.2 1.4h6.7V7.4" fill="rgba(122,131,140,.16)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
<path d="M1.4 12.6l1.9-5.1h11.4l-1.9 5.1H1.4z" fill="rgba(122,131,140,.22)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
</Fragment>
|
||||
) : (
|
||||
<path d="M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z" fill="rgba(122,131,140,.12)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function FileIcon({ path }: { path: string }): React.ReactElement {
|
||||
const ic = HL.iconFor(path)
|
||||
return <span className="ficon" style={{ background: ic.c }}><span>{ic.t}</span></span>
|
||||
}
|
||||
|
||||
/* Shared callback signatures used across panels. */
|
||||
export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number; side?: DiffSide }) => void
|
||||
export interface ContextTarget {
|
||||
path: string
|
||||
/** 'root' = the empty space under the tree, i.e. the project folder itself. */
|
||||
kind: 'editor' | 'dir' | 'file' | 'git' | 'root'
|
||||
staged?: boolean
|
||||
sel?: { start: number; end: number }
|
||||
line?: number
|
||||
code?: string
|
||||
}
|
||||
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
||||
|
||||
/* ============ Git / Source Control panel ============ */
|
||||
|
||||
/** True for files under the project root's `tests/` or `cypress/` folder. The git list dims these. */
|
||||
function isTestFile(path: string): boolean {
|
||||
return path.startsWith('tests/') || path.startsWith('cypress/')
|
||||
}
|
||||
|
||||
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext, onToggleStage }: {
|
||||
c: Change
|
||||
activePath: string | null
|
||||
/** Which half the open tab is showing, so only that row lights up. */
|
||||
activeSide: DiffSide | null
|
||||
ctxPath: string | null
|
||||
kbdId: string | null
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
onToggleStage: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
const staged = c.staged
|
||||
const side: DiffSide = staged ? 'staged' : 'unstaged'
|
||||
const name = c.path.split('/').pop()
|
||||
const isActive = activePath === c.path && (!activeSide || activeSide === side)
|
||||
return (
|
||||
<div className={'git-row' + (staged ? ' staged' : '') + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
|
||||
data-row-id={c.id}
|
||||
onClick={() => onOpen(c.path, { side })}
|
||||
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>
|
||||
<button className="git-act push" title={staged ? 'Unstage changes' : 'Stage changes'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
|
||||
{staged ? Icon.minus() : Icon.plus()}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* 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[] }[] {
|
||||
const out: { dir: string; rows: Change[] }[] = []
|
||||
const byDir = new Map<string, { dir: string; rows: Change[] }>()
|
||||
for (const c of list) {
|
||||
const dir = c.path.split('/').slice(0, -1).join('/')
|
||||
const group = byDir.get(dir)
|
||||
if (group) group.rows.push(c)
|
||||
else { const g = { dir, rows: [c] }; byDir.set(dir, g); out.push(g) }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function GitList({ list, ...row }: {
|
||||
list: Change[]
|
||||
activePath: string | null
|
||||
activeSide: DiffSide | null
|
||||
ctxPath: string | null
|
||||
kbdId: string | null
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
onToggleStage: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Fragment>
|
||||
{groupByDir(list).map((g) => (
|
||||
<Fragment key={g.dir}>
|
||||
<div className="git-dir-head" title={g.dir || '(project root)'}>
|
||||
<FolderIcon open={false} />
|
||||
<span className="gdh-path">{g.dir || '.'}</span>
|
||||
</div>
|
||||
{g.rows.map((c) => <GitRow key={c.id} c={c} {...row} />)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId }: {
|
||||
branch: string
|
||||
changes: Change[]
|
||||
committed: Set<string>
|
||||
commitMsg: string
|
||||
setCommitMsg: (v: string) => void
|
||||
onStage: (path: string) => void
|
||||
onUnstage: (path: string) => void
|
||||
onStageAll: () => void
|
||||
onUnstageAll: () => void
|
||||
onCommit: () => void
|
||||
onPush: () => void
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
activeSide: DiffSide | null
|
||||
ctxPath: string | null
|
||||
kbdId: string | null
|
||||
}): React.ReactElement {
|
||||
const visible = changes.filter((c) => !committed.has(c.path))
|
||||
const stagedList = visible.filter((c) => c.staged)
|
||||
const changesList = visible.filter((c) => !c.staged)
|
||||
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Shift+Enter to commit"
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
|
||||
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
|
||||
</div>
|
||||
|
||||
<div className="git-body">
|
||||
{visible.length === 0 ? (
|
||||
<div className="git-empty">— No changes. The working tree is clean.</div>
|
||||
) : (
|
||||
<Fragment>
|
||||
<div className="git-group">
|
||||
Staged Changes
|
||||
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
|
||||
<span className="gc">{stagedList.length}</span>
|
||||
</div>
|
||||
{stagedList.length > 0 ? (
|
||||
<GitList list={stagedList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||
) : (
|
||||
<div className="git-none">— Nothing staged.</div>
|
||||
)}
|
||||
|
||||
<div className="git-divider" />
|
||||
|
||||
<div className="git-group">
|
||||
Changes
|
||||
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
|
||||
<span className="gc">{changesList.length}</span>
|
||||
</div>
|
||||
{changesList.length > 0 ? (
|
||||
<GitList list={changesList} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||
) : (
|
||||
<div className="git-none">— All changes are staged.</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The line counts live in the status bar; this footer only counts files. */}
|
||||
<div className="git-foot">
|
||||
<span>{stagedList.length} staged · {changesList.length} unstaged</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============ File Tree ============ */
|
||||
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
|
||||
node: FileNode
|
||||
depth: number
|
||||
openDirs: Set<string>
|
||||
toggleDir: (path: string) => void
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
ctxPath: string | null
|
||||
kbdPath: string | null
|
||||
changeMap: Record<string, GitStatus>
|
||||
committed: Set<string>
|
||||
showHidden: boolean
|
||||
}): React.ReactElement {
|
||||
const pad = 12 + depth * 16
|
||||
if (node.type === 'dir') {
|
||||
const isOpen = openDirs.has(node.path) || node.path === ''
|
||||
return (
|
||||
<Fragment>
|
||||
{node.path !== '' && (
|
||||
<div className={'tree-row folder' + (isOpen ? ' open' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
|
||||
data-row-path={node.path}
|
||||
onClick={() => toggleDir(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
||||
<span className="tw"><Chevron open={isOpen} /></span>
|
||||
<FolderIcon open={isOpen} />
|
||||
<span className="tree-label">{node.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (node.children || [])
|
||||
.filter((c) => showHidden || !c.name.startsWith('.'))
|
||||
.map((c) => (
|
||||
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
|
||||
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
|
||||
onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
||||
return (
|
||||
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
|
||||
style={{ paddingLeft: pad }}
|
||||
data-row-path={node.path}
|
||||
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)' } : undefined}>{node.name}</span>
|
||||
{status && <span className={'tree-badge ' + status}>{status}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
|
||||
tree: FileNode
|
||||
openDirs: Set<string>
|
||||
toggleDir: (path: string) => void
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
ctxPath: string | null
|
||||
kbdPath: string | null
|
||||
changeMap: Record<string, GitStatus>
|
||||
committed: Set<string>
|
||||
showHidden: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Right-clicking the empty space under the rows targets the project root.
|
||||
Row menus stop propagation, so this only fires on the bare background. */}
|
||||
<div className="tree-body" onContextMenu={(e) => onContext(e, { path: '', kind: 'root' })}>
|
||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
||||
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
|
||||
(function () {
|
||||
// ---- working-tree (current / updated) file contents ----------------
|
||||
const F = {};
|
||||
/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff.
|
||||
*
|
||||
* PHASE 2: replace this whole module with real data streamed from the main
|
||||
* process — file tree (chokidar), file contents on demand, and `git diff`
|
||||
* derived original/updated pairs. The four view modes still derive from the
|
||||
* same original/updated text pair per changed file, so keep buildDiff()'s
|
||||
* output shape. */
|
||||
import type { Change, Diff, FileNode, Project } from './types'
|
||||
import { rowId } from './types'
|
||||
import { buildDiff } from './diff'
|
||||
|
||||
F["src/Http/Controller/UserController.php"] = `<?php
|
||||
// ---- working-tree (current / updated) file contents ----------------
|
||||
const F: Record<string, string> = {}
|
||||
|
||||
F['src/Http/Controller/UserController.php'] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
@@ -60,9 +69,9 @@ final class UserController
|
||||
return array_intersect_key($data, array_flip($allowed));
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["src/Service/PaymentService.php"] = `<?php
|
||||
F['src/Service/PaymentService.php'] = `<?php
|
||||
|
||||
namespace App\\Service;
|
||||
|
||||
@@ -101,9 +110,9 @@ final class PaymentService
|
||||
return $result->success;
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
F['public/assets/app.js'] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
@@ -135,9 +144,9 @@ function renderToasts(list) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
`
|
||||
|
||||
F["public/assets/store.js"] = `export function createStore(initial = {}) {
|
||||
F['public/assets/store.js'] = `export function createStore(initial = {}) {
|
||||
let state = { ...initial };
|
||||
const subs = new Map();
|
||||
|
||||
@@ -155,9 +164,9 @@ document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["public/assets/styles.css"] = `:root {
|
||||
F['public/assets/styles.css'] = `:root {
|
||||
--brand: #4d8dff;
|
||||
--ink: #15171a;
|
||||
--paper: #ffffff;
|
||||
@@ -179,9 +188,9 @@ body {
|
||||
|
||||
.toast--error { border-left-color: #e0696a; }
|
||||
.toast--success { border-left-color: #5cbd6b; }
|
||||
`;
|
||||
`
|
||||
|
||||
F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
|
||||
F['src/types/api.ts'] = `export type Plan = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -209,9 +218,9 @@ export async function getUser(id: string): Promise<ApiResult<User>> {
|
||||
}
|
||||
return { ok: true, data: (await res.json()) as User };
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
F['scripts/migrate.py'] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -254,9 +263,9 @@ def main() -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
`
|
||||
|
||||
F["scripts/seed.py"] = `#!/usr/bin/env python3
|
||||
F['scripts/seed.py'] = `#!/usr/bin/env python3
|
||||
"""Seed the database with demo data for local development."""
|
||||
import random
|
||||
from db import connect
|
||||
@@ -277,9 +286,9 @@ def seed_users(conn, count: int = 25) -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_users(connect())
|
||||
`;
|
||||
`
|
||||
|
||||
F["templates/dashboard.html"] = `<!doctype html>
|
||||
F['templates/dashboard.html'] = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -302,9 +311,9 @@ if __name__ == "__main__":
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
`
|
||||
|
||||
F["config/app.json"] = `{
|
||||
F['config/app.json'] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
@@ -322,9 +331,9 @@ if __name__ == "__main__":
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["composer.json"] = `{
|
||||
F['composer.json'] = `{
|
||||
"name": "blijnder/console",
|
||||
"type": "project",
|
||||
"require": {
|
||||
@@ -341,9 +350,9 @@ if __name__ == "__main__":
|
||||
"psr-4": { "App\\\\": "src/" }
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["package.json"] = `{
|
||||
F['package.json'] = `{
|
||||
"name": "console-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -359,9 +368,9 @@ if __name__ == "__main__":
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
F["README.md"] = `# Console
|
||||
F['README.md'] = `# Console
|
||||
|
||||
Internal admin console. PHP API + small vanilla JS frontend.
|
||||
|
||||
@@ -374,24 +383,24 @@ Internal admin console. PHP API + small vanilla JS frontend.
|
||||
|
||||
## Layout
|
||||
|
||||
- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
|
||||
- \`public/\` Document root and frontend assets
|
||||
- \`scripts/\` Python maintenance + migration scripts
|
||||
- \`templates/\` Server-rendered HTML
|
||||
`;
|
||||
- \\\`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
|
||||
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 = {};
|
||||
const O: Record<string, string> = {}
|
||||
|
||||
O["src/Http/Controller/UserController.php"] = `<?php
|
||||
O['src/Http/Controller/UserController.php'] = `<?php
|
||||
|
||||
namespace App\\Http\\Controller;
|
||||
|
||||
@@ -444,9 +453,9 @@ final class UserController
|
||||
return array_intersect_key($data, array_flip(['email', 'plan']));
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
O["public/assets/app.js"] = `import { createStore } from './store.js';
|
||||
O['public/assets/app.js'] = `import { createStore } from './store.js';
|
||||
import { mountRouter } from './router.js';
|
||||
|
||||
const store = createStore({
|
||||
@@ -477,9 +486,9 @@ function renderToasts(list) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
`;
|
||||
`
|
||||
|
||||
O["config/app.json"] = `{
|
||||
O['config/app.json'] = `{
|
||||
"name": "console",
|
||||
"env": "production",
|
||||
"features": {
|
||||
@@ -496,9 +505,9 @@ document.addEventListener('DOMContentLoaded', bootstrap);
|
||||
"channel": "stdout"
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
O["scripts/migrate.py"] = `#!/usr/bin/env python3
|
||||
O['scripts/migrate.py'] = `#!/usr/bin/env python3
|
||||
"""Run pending database migrations in order."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -540,13 +549,13 @@ def main() -> int:
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
`;
|
||||
`
|
||||
|
||||
// PaymentService is a brand-new file (added) -> original is empty
|
||||
O["src/Service/PaymentService.php"] = "";
|
||||
O['src/Service/PaymentService.php'] = ''
|
||||
|
||||
// LegacyUser was deleted -> original content, no working-tree version
|
||||
O["src/Model/LegacyUser.php"] = `<?php
|
||||
O['src/Model/LegacyUser.php'] = `<?php
|
||||
|
||||
namespace App\\Model;
|
||||
|
||||
@@ -580,133 +589,79 @@ final class LegacyUser
|
||||
];
|
||||
}
|
||||
}
|
||||
`;
|
||||
`
|
||||
|
||||
// ---- 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" },
|
||||
const tree: FileNode = {
|
||||
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: '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: '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: '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: '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: '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: '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" },
|
||||
{ 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 changeDefs: { path: string; status: Change['status'] }[] = [
|
||||
{ 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);
|
||||
const diffs: Record<string, Diff> = {}
|
||||
const changes: Change[] = 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",
|
||||
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" };
|
||||
});
|
||||
})
|
||||
// Mock rows are all unstaged here; project.tsx flips the staged ones over.
|
||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: false, id: rowId(c.path, false) }
|
||||
})
|
||||
|
||||
window.PROJECT = {
|
||||
name: "console",
|
||||
branch: "feat/payments-balance",
|
||||
export const PROJECT: Project = {
|
||||
name: 'console',
|
||||
branch: 'feat/payments-balance',
|
||||
files: F,
|
||||
originals: O,
|
||||
tree,
|
||||
diffs,
|
||||
changes,
|
||||
};
|
||||
})();
|
||||
}
|
||||
142
src/renderer/src/diff.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/* Line-based LCS diff — the single source for the view modes. Actual, Original
|
||||
* and the side-by-side Diff all derive from one (original, updated) text pair
|
||||
* per changed file, whether that pair comes from the mock or from real
|
||||
* `git diff` (original = HEAD:path, updated = working tree). */
|
||||
import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
|
||||
|
||||
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }
|
||||
|
||||
/** Largest DP matrix we build: 4M cells = 16 MB of Int32. A region over the
|
||||
* budget is split on unique anchor lines instead, because the full n*m matrix
|
||||
* of a big file exhausts the heap and kills the renderer. */
|
||||
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))
|
||||
for (let i = n - 1; i >= 0; i--)
|
||||
for (let j = m - 1; j >= 0; j--)
|
||||
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])
|
||||
|
||||
let i = 0, j = 0
|
||||
while (i < n && j < m) {
|
||||
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: a0 + i }); i++ }
|
||||
else { ops.push({ t: 'add', b: b0 + j }); j++ }
|
||||
}
|
||||
while (i < n) ops.push({ t: 'del', a: a0 + i++ })
|
||||
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 delSet = new Set<number>(), addSet = new Set<number>()
|
||||
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: SideLine[] = [], abuf: SideLine[] = []
|
||||
const flush = (): void => {
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Assemble a full Diff from a status + original/updated pair. */
|
||||
export function makeDiff(status: GitStatus, original: string, updated: string): Diff {
|
||||
const d = buildDiff(original, updated)
|
||||
return { ...d, deleted: status === 'D', added: status === 'A', original, updated }
|
||||
}
|
||||
870
src/renderer/src/editor.tsx
Normal file
@@ -0,0 +1,870 @@
|
||||
/* Editor: four view modes (Actual / Original / Diff / Preview) + line selection */
|
||||
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Diff, DiffSide, ViewLine } from './types'
|
||||
import { rowId } from './types'
|
||||
import { useProject } from './project'
|
||||
import { HL } from './highlight'
|
||||
import { isKnownSymbol, useSymbols } from './symbols'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { rlog } from './log'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import type { OnContext } from './components'
|
||||
|
||||
/** ⌘-click on a class name declared in this project. */
|
||||
export type OnSymbol = (name: string) => void
|
||||
|
||||
/** The identifier around `pos` in `text`, or '' when the caret is not on one. */
|
||||
export function wordAt(text: string, pos: number): string {
|
||||
if (pos < 0 || pos > text.length) return ''
|
||||
const word = /\w/
|
||||
let a = pos
|
||||
while (a > 0 && word.test(text[a - 1])) a--
|
||||
let b = pos
|
||||
while (b < text.length && word.test(text[b])) b++
|
||||
const w = text.slice(a, b)
|
||||
return /^[A-Za-z_]\w*$/.test(w) ? w : ''
|
||||
}
|
||||
|
||||
/** True while ⌘ (or Ctrl) is held. Drives the underline, so the affordance
|
||||
* shows exactly when the click would do something. */
|
||||
function useMetaKey(): boolean {
|
||||
const [down, setDown] = useState(false)
|
||||
useEffect(() => {
|
||||
const on = (e: KeyboardEvent): void => { if (e.metaKey || e.ctrlKey) setDown(true) }
|
||||
const off = (e: KeyboardEvent): void => { if (!e.metaKey && !e.ctrlKey) setDown(false) }
|
||||
const clear = (): void => setDown(false)
|
||||
window.addEventListener('keydown', on)
|
||||
window.addEventListener('keyup', off)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', on)
|
||||
window.removeEventListener('keyup', off)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
return down
|
||||
}
|
||||
|
||||
export interface Cursor { path: string; line: number; col: number }
|
||||
/** A one-second line highlight after a jump. `id` changes per jump, so the same
|
||||
* line twice restarts the animation. */
|
||||
export interface FlashLine { line: number; id: number }
|
||||
export interface Selection { path: string; start: number; end: number; anchor: number }
|
||||
export type Mode = 'original' | 'updated' | 'diff' | 'code' | 'preview'
|
||||
|
||||
function climbToLine(node: Node | null): HTMLElement | null {
|
||||
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null)
|
||||
while (el && !(el.dataset && el.dataset.line)) el = el.parentElement
|
||||
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
|
||||
* scroll-synced line-number gutter. Live highlighting while typing.
|
||||
*
|
||||
* Two layouts, picked by `wrap`:
|
||||
* - no wrap: one highlighted blob, lines never break, gutter is a separate
|
||||
* column that follows the vertical scroll.
|
||||
* - wrap: long lines fold onto the next visual row, so a fixed 20px-per-line
|
||||
* gutter no longer lines up. Each line becomes its own `.ce-line` block and
|
||||
* 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
|
||||
* line (like the diff views), so multi-line tokens don't carry over. */
|
||||
function CodeEditor({ path, text, lang, wrap, flash, marks, onPick, onChange, onContext, onSymbol }: {
|
||||
path: string
|
||||
text: string
|
||||
lang: string | null
|
||||
wrap: boolean
|
||||
/** Line to paint orange for a second after a jump. */
|
||||
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
|
||||
onContext: OnContext
|
||||
onSymbol: OnSymbol
|
||||
}): React.ReactElement {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const innerRef = useRef<HTMLDivElement>(null)
|
||||
const gutterRef = useRef<HTMLDivElement>(null)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
const tabSize = useProject().config.editor.tabSize
|
||||
const symVer = useSymbols() // re-highlight once the declared-name list lands
|
||||
const metaDown = useMetaKey()
|
||||
const tinted = useMemo(() => new Set(marks.filter((m) => m.tint).map((m) => m.line)), [marks])
|
||||
const html = useMemo(
|
||||
() => (wrap
|
||||
? 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'),
|
||||
// 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.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[text, lang, wrap, symVer, tinted],
|
||||
)
|
||||
/* One block of markup, not one element per line: the font is monospace and
|
||||
* every row is exactly 20px, so the numbers land on the same grid a list of
|
||||
* divs gave — for one node instead of thousands on a long file. Only a
|
||||
* changed number needs an element of its own. */
|
||||
const gutter = useMemo(() => {
|
||||
const n = text.split('\n').length
|
||||
const out: string[] = []
|
||||
for (let i = 1; i <= n; i++) out.push(tinted.has(i) ? `<span class="chg">${i}</span>` : String(i))
|
||||
return out.join('\n')
|
||||
}, [text, tinted])
|
||||
// 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.
|
||||
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (!flash) { setFlashBox(null); return }
|
||||
const row = wrap ? (preRef.current?.children[flash.line - 1] as HTMLElement | undefined) : undefined
|
||||
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
|
||||
}, [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 {
|
||||
const s = scrollRef.current
|
||||
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
|
||||
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
|
||||
// When wrapping, a line can be taller than one row, so measure its block.
|
||||
function ensureCaretVisible(ta: HTMLTextAreaElement): void {
|
||||
const s = scrollRef.current
|
||||
if (!s) return
|
||||
const line = ta.value.slice(0, ta.selectionStart).split('\n').length - 1
|
||||
const row = wrap ? (preRef.current?.children[line] as HTMLElement | undefined) : undefined
|
||||
const top = row ? row.offsetTop : 6 + line * 20
|
||||
const height = row ? row.offsetHeight : 20
|
||||
const bottom = top + height
|
||||
if (top < s.scrollTop) s.scrollTop = top - height
|
||||
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + height
|
||||
}
|
||||
/* Tab indents, it does not move focus out of the editor.
|
||||
* Plain Tab on one line inserts spaces. Tab over a multi-line selection
|
||||
* indents every line it touches. Shift+Tab outdents.
|
||||
* We write through execCommand so the browser keeps its own undo history. */
|
||||
function replace(ta: HTMLTextAreaElement, from: number, to: number, text: string): void {
|
||||
ta.setSelectionRange(from, to)
|
||||
if (document.execCommand?.('insertText', false, text)) return
|
||||
// No execCommand (jsdom): splice by hand. Costs the native undo step.
|
||||
onChange(ta.value.slice(0, from) + text + ta.value.slice(to))
|
||||
}
|
||||
function handleTab(e: React.KeyboardEvent<HTMLTextAreaElement>, out: boolean): void {
|
||||
e.preventDefault()
|
||||
const ta = e.currentTarget
|
||||
const pad = ' '.repeat(tabSize)
|
||||
const from = ta.selectionStart
|
||||
const to = ta.selectionEnd
|
||||
if (!out && !ta.value.slice(from, to).includes('\n')) {
|
||||
replace(ta, from, to, pad)
|
||||
ta.setSelectionRange(from + pad.length, from + pad.length)
|
||||
ensureCaretVisible(ta)
|
||||
return
|
||||
}
|
||||
// Rewrite whole lines, so grow the range to the line edges first. A
|
||||
// selection that stops at column 0 leaves that last line alone.
|
||||
const start = ta.value.lastIndexOf('\n', from - 1) + 1
|
||||
const tail = to > from && ta.value[to - 1] === '\n' ? to - 1 : to
|
||||
const nl = ta.value.indexOf('\n', tail)
|
||||
const end = nl === -1 ? ta.value.length : nl
|
||||
const lines = ta.value.slice(start, end).split('\n')
|
||||
const lead = new RegExp(`^(\t| {1,${tabSize}})`)
|
||||
const cut = (line: string): number => (out ? (lead.exec(line)?.[0].length ?? 0) : 0)
|
||||
const next = lines.map((line) => (out ? line.slice(cut(line)) : pad + line)).join('\n')
|
||||
if (next === ta.value.slice(start, end)) return
|
||||
const head = out ? -cut(lines[0]) : pad.length
|
||||
const total = out ? -lines.reduce((n, line) => n + cut(line), 0) : pad.length * lines.length
|
||||
replace(ta, start, end, next)
|
||||
ta.setSelectionRange(Math.max(start, from + head), Math.max(start, to + total))
|
||||
ensureCaretVisible(ta)
|
||||
}
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
|
||||
}
|
||||
/* Two ways in. While ⌘ is held the marked tokens take the mouse (see the
|
||||
* .sym-live rules), so a click on one arrives here with the token as target
|
||||
* and its text is the name. Anywhere else the click lands on the textarea,
|
||||
* which has already moved the caret, so the word around `selectionStart` is
|
||||
* the word under the pointer — no token geometry either way. */
|
||||
function handleTokenClick(e: React.MouseEvent): void {
|
||||
if (!e.metaKey && !e.ctrlKey) return
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.classList?.contains('sym')) return
|
||||
e.preventDefault()
|
||||
onSymbol(el.textContent || '')
|
||||
}
|
||||
|
||||
function handleClick(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
||||
ensureCaretVisible(e.currentTarget)
|
||||
if (!e.metaKey && !e.ctrlKey) return
|
||||
const name = wordAt(e.currentTarget.value, e.currentTarget.selectionStart)
|
||||
if (name && isKnownSymbol(name)) { e.preventDefault(); onSymbol(name) }
|
||||
}
|
||||
|
||||
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
|
||||
e.preventDefault()
|
||||
const ta = e.currentTarget
|
||||
const startLine = text.slice(0, ta.selectionStart).split('\n').length
|
||||
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
|
||||
if (ta.selectionEnd > ta.selectionStart) {
|
||||
const endLine = text.slice(0, ta.selectionEnd).split('\n').length
|
||||
info.sel = { start: startLine, end: endLine }
|
||||
info.code = ta.value.slice(ta.selectionStart, ta.selectionEnd)
|
||||
}
|
||||
onContext(e, info)
|
||||
}
|
||||
return (
|
||||
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
|
||||
{!wrap && (
|
||||
<div className="ce-gutterwrap">
|
||||
<div className="ce-gutter" ref={gutterRef} dangerouslySetInnerHTML={{ __html: gutter }} />
|
||||
</div>
|
||||
)}
|
||||
{/* No marks, no pick: an unchanged file pays nothing for the reveal. */}
|
||||
<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 }} />}
|
||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
|
||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContext} />
|
||||
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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 (
|
||||
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
|
||||
<div className="md-body" ref={body} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Image preview: fetches the file as a data: URL from main (the renderer can't
|
||||
* read the filesystem) and shows it centred on the editor surface. Read-only. */
|
||||
function ImageView({ path, onContext }: { path: string; onContext: OnContext }): React.ReactElement {
|
||||
const [src, setSrc] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setSrc(''); setFailed(false)
|
||||
const bridge = window.helder
|
||||
if (!bridge) { setFailed(true); return }
|
||||
bridge.fs.imageDataUrl(path)
|
||||
.then((url) => { if (alive) { if (url) setSrc(url); else setFailed(true) } })
|
||||
.catch(() => { if (alive) setFailed(true) })
|
||||
return () => { alive = false }
|
||||
}, [path])
|
||||
return (
|
||||
<div className="img-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
|
||||
{src
|
||||
? <img className="img-view-img" src={src} alt={path.split('/').pop()} />
|
||||
: failed
|
||||
? <div className="empty-ed"><div className="big" style={{ color: 'var(--fg-3)' }}>Can’t preview this image</div></div>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Generic pane: renders an array of line descriptors with selection + caret + context. */
|
||||
function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selection, setCursor, setSelection, onContext, onSymbol }: {
|
||||
cacheKey: string
|
||||
path: string
|
||||
lines: ViewLine[]
|
||||
lang: string | null
|
||||
showSign: boolean
|
||||
/** Fold long lines onto the next row. The number column stays on row one. */
|
||||
wrap: boolean
|
||||
cursor: Cursor | null
|
||||
selection: Selection | null
|
||||
setCursor: (c: Cursor) => void
|
||||
setSelection: (s: Selection | null) => void
|
||||
onContext: OnContext
|
||||
onSymbol: OnSymbol
|
||||
}): React.ReactElement {
|
||||
const anchorRef = useRef<number | null>(null)
|
||||
const symVer = useSymbols()
|
||||
const metaDown = useMetaKey()
|
||||
const html = useMemo(() => lines.map((l) => HL.hlLine(l.text, lang)), [cacheKey, symVer])
|
||||
/* These rows are real spans, so the marked token is the click target itself. */
|
||||
function onSymbolClick(e: React.MouseEvent): void {
|
||||
if (!e.metaKey && !e.ctrlKey) return
|
||||
const el = e.target as HTMLElement
|
||||
if (!el.classList?.contains('sym')) return
|
||||
e.preventDefault()
|
||||
onSymbol(el.textContent || '')
|
||||
}
|
||||
|
||||
function gutterClick(e: React.MouseEvent, no: number | null): void {
|
||||
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: globalThis.Selection): number {
|
||||
try {
|
||||
const el = climbToLine(sel.focusNode)
|
||||
const code = el!.querySelector('.ln-code') as Element
|
||||
const r = document.createRange()
|
||||
r.setStart(code, 0); r.setEnd(sel.focusNode!, sel.focusOffset)
|
||||
return r.toString().length + 1
|
||||
} catch {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
function onMouseUp(): void {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
// Join the source lines covered by a selection — the code-block payload for "Pass on selection".
|
||||
function codeForRange(s: number, en: number): string {
|
||||
return lines.filter((l) => l.no != null && l.no >= s && l.no <= en).map((l) => l.text).join('\n')
|
||||
}
|
||||
function handleContext(e: React.MouseEvent): void {
|
||||
e.preventDefault()
|
||||
const sel = window.getSelection()
|
||||
const info: Parameters<OnContext>[1] = { 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; info.code = codeForRange(s, en)
|
||||
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
|
||||
info.code = codeForRange(selection.start, selection.end)
|
||||
} else {
|
||||
let no: number | null = 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' : '') + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}
|
||||
onMouseUp={onMouseUp} onClick={onSymbolClick} 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' : '')
|
||||
+ (l.gap === 'above' ? ' gap-above' : l.gap === 'below' ? ' gap-below' : '')
|
||||
+ (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>
|
||||
)
|
||||
}
|
||||
|
||||
/** The original lines behind one current line: the range to mark amber, and the
|
||||
* one to line up with the picked row. */
|
||||
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 } {
|
||||
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 }
|
||||
// plain file
|
||||
const arr = (fileText || '').replace(/\n$/, '').split('\n')
|
||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
|
||||
}
|
||||
|
||||
/** View options available for a file, given whether it has a diff and whether
|
||||
* it's markdown. Unchanged files only have the plain editable "Code" view; a
|
||||
* changed file gets the Updated/Original/Diff trio; markdown adds "Preview". */
|
||||
function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label: string }[] {
|
||||
const segs: { id: Mode; label: string }[] = hasDiff
|
||||
? [{ id: 'updated', label: 'Actual' }, { id: 'original', label: 'Original' }, { id: 'diff', label: 'Diff' }]
|
||||
: [{ id: 'code', label: isMarkdown ? 'Actual' : 'Code' }]
|
||||
if (isMarkdown) segs.push({ id: 'preview', label: 'Preview' })
|
||||
return segs
|
||||
}
|
||||
|
||||
export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
|
||||
active: string | null
|
||||
mode: Mode
|
||||
/** Which git row opened this tab. Only Diff follows it. */
|
||||
side: DiffSide | null
|
||||
setMode: (m: Mode) => void
|
||||
onContext: OnContext
|
||||
/** The original of the line under the pointer, for the overlay App renders. */
|
||||
onPeek: (p: PeekInfo | null) => void
|
||||
cursor: Cursor | null
|
||||
selection: Selection | null
|
||||
setCursor: (c: Cursor) => void
|
||||
setSelection: (s: Selection | null) => void
|
||||
/** Line to flash after a jump, when it belongs to the open file. */
|
||||
flash: FlashLine | null
|
||||
bufferText: string
|
||||
onEdit: (text: string) => void
|
||||
/** ⌘-click on a class name declared in this project. */
|
||||
onSymbol: OnSymbol
|
||||
}): React.ReactElement {
|
||||
const PROJECT = useProject()
|
||||
const tab = active ? { path: active } : null
|
||||
const isImage = tab ? HL.isImage(tab.path) : false
|
||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
|
||||
// Original / Actual always show the whole file: HEAD vs disk.
|
||||
const diff = tab ? PROJECT.diffs[tab.path] : null
|
||||
// Diff shows the half you clicked in the git panel. A file with only one row
|
||||
// has an identical pair either way.
|
||||
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 lang = tab ? HL.langFor(tab.path) : null
|
||||
const hasDiff = !isImage && !!(change && diff)
|
||||
const isMarkdown = lang === 'markdown'
|
||||
const segments = segmentsFor(hasDiff, isMarkdown)
|
||||
// Long lines fold instead of scrolling sideways. Default: Markdown only, where
|
||||
// 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.
|
||||
const wordWrap = PROJECT.config.editor.wordWrap
|
||||
const wrap = wordWrap === 'on' || (wordWrap === 'markdown' && isMarkdown)
|
||||
|
||||
// Resolve the requested mode against what this file actually supports, so a
|
||||
// mode carried over from another file (or an unchanged file asked for a diff
|
||||
// view) falls back sensibly instead of rendering blank.
|
||||
let effMode: Mode
|
||||
if (mode === 'preview' && isMarkdown) effMode = 'preview'
|
||||
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
|
||||
else effMode = 'code'
|
||||
|
||||
// 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
|
||||
/* 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
|
||||
if (tab && paneMode !== 'preview') {
|
||||
built = hasDiff ? buildLines(paneMode, diff, PROJECT.files[tab.path]) : buildLines('code', null, PROJECT.files[tab.path])
|
||||
}
|
||||
|
||||
// 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.
|
||||
const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status
|
||||
const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified'
|
||||
const activeSeg = effMode
|
||||
// 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
|
||||
// the editor rather than the "deleted" placeholder.
|
||||
const emptyUpdated = paneMode === 'updated' && fileStatus === 'D'
|
||||
const emptyOriginal = paneMode === 'original' && fileStatus === 'A'
|
||||
// Editable in the live-buffer modes; Original/Preview stay read-only views.
|
||||
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 (
|
||||
<Fragment>
|
||||
{!tab ? (
|
||||
<div className="empty-ed">
|
||||
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||
<div className="big">No file open</div>
|
||||
{/* Only what works with no file open — Copy reference and Pass on to
|
||||
Agent need a file, so they are not advertised here. */}
|
||||
<div className="klist">
|
||||
<div><span>Open folder</span><kbd>⌘O</kbd></div>
|
||||
<div><span>Recent projects</span><kbd>⇧⌘O</kbd></div>
|
||||
<div><span>Search files & content</span><kbd>⌘F</kbd></div>
|
||||
<div><span>Project note</span><kbd>⌘N</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-wrap">
|
||||
{/* The view bar is always present; what it offers depends on the file
|
||||
state (changed → diff views, markdown → Preview, else just Code). */}
|
||||
<div className="diff-bar">
|
||||
{change ? (
|
||||
<Fragment>
|
||||
<span className={'git-stat ' + fileStatus} style={{ width: 'auto' }}>{statusWord}</span>
|
||||
{!!shown && shown.add > 0 && <span className="a">+{shown.add}</span>}
|
||||
{!!shown && shown.del > 0 && <span className="d">−{shown.del}</span>}
|
||||
{/* Only ambiguous when the file is staged AND edited again: say
|
||||
which pair the diff is comparing. */}
|
||||
{bothSides && effMode === 'diff' && (
|
||||
<span className="db-side">{side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<span className="db-lang">{isImage ? 'Image' : HL.langLabel(tab.path)}</span>
|
||||
)}
|
||||
{!isImage && (
|
||||
<div className="seg">
|
||||
{segments.map((s) => (
|
||||
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isImage ? (
|
||||
<ImageView path={tab.path} onContext={onContext} />
|
||||
) : paneMode === 'preview' ? (
|
||||
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
|
||||
) : emptyUpdated ? (
|
||||
<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 ? (
|
||||
<NoOriginal />
|
||||
) : editable ? (
|
||||
<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 + ':' + paneMode} path={tab.path} lines={built.lines}
|
||||
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
|
||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 }: {
|
||||
path: string
|
||||
/** Which git row opened this file. Diff compares that row's pair. */
|
||||
side: DiffSide | null
|
||||
onClose: () => void
|
||||
onContext: OnContext
|
||||
}): React.ReactElement {
|
||||
const PROJECT = useProject()
|
||||
const diff = (side ? PROJECT.rowDiffs[rowId(path, side === 'staged')] : null) || PROJECT.diffs[path]
|
||||
const lang = HL.langFor(path)
|
||||
const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null)
|
||||
const lock = useRef(false)
|
||||
const change = PROJECT.changes.find((c) => c.path === path)
|
||||
const splitStatus = diff.deleted ? 'D' : diff.added ? 'A' : 'M'
|
||||
const bothSides = !!PROJECT.rowDiffs[rowId(path, true)] && !!PROJECT.rowDiffs[rowId(path, false)]
|
||||
|
||||
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path, side])
|
||||
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path, side])
|
||||
|
||||
function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void {
|
||||
if (lock.current || !from || !to) return
|
||||
lock.current = true
|
||||
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft
|
||||
requestAnimationFrame(() => { lock.current = false })
|
||||
}
|
||||
function ctx(e: React.MouseEvent): void {
|
||||
e.preventDefault()
|
||||
let no: number | null = 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 ' + splitStatus} style={{ width: 'auto' }}>{splitStatus === 'A' ? 'Added' : splitStatus === 'D' ? 'Deleted' : 'Modified'}</span>}
|
||||
{diff.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{diff.add}</span>}
|
||||
{diff.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>−{diff.del}</span>}
|
||||
{bothSides && <span className="db-side">{side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}</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={ctx}>
|
||||
{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' ? ' 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">Actual <span>after</span></div>
|
||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
|
||||
{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' : '')}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
94
src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
/// <reference types="vite/client" />
|
||||
import type { FileNode, GitStatus, HelderConfig, SymbolLookup } from './types'
|
||||
|
||||
interface GitChangeRaw {
|
||||
path: string
|
||||
status: GitStatus
|
||||
staged: boolean
|
||||
original: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
interface HelderBridge {
|
||||
platform: string
|
||||
clipboard: { writeText: (text: string) => void; readText: () => string }
|
||||
project: {
|
||||
current: () => Promise<{ root: string | null; name: string }>
|
||||
open: () => Promise<{ root: string | null; name: string }>
|
||||
openPath: (path: string) => Promise<{ root: string | null; name: string }>
|
||||
recent: () => Promise<{ path: string; name: string }[]>
|
||||
}
|
||||
fs: {
|
||||
tree: () => Promise<FileNode | null>
|
||||
readDir: (path: string) => Promise<FileNode[]>
|
||||
files: () => Promise<Record<string, string>>
|
||||
read: (path: string) => Promise<string>
|
||||
imageDataUrl: (path: string) => Promise<string>
|
||||
write: (path: string, content: string) => Promise<void>
|
||||
delete: (path: string) => Promise<void>
|
||||
create: (path: string) => Promise<void>
|
||||
mkdir: (path: string) => Promise<void>
|
||||
rename: (path: string, name: string) => Promise<string>
|
||||
}
|
||||
shell: {
|
||||
reveal: (path: string) => void
|
||||
}
|
||||
notes: {
|
||||
read: () => Promise<string>
|
||||
write: (text: string) => Promise<void>
|
||||
}
|
||||
git: {
|
||||
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
||||
stage: (paths: string[]) => Promise<void>
|
||||
unstage: (paths: string[]) => Promise<void>
|
||||
commit: (message: string) => Promise<void>
|
||||
push: () => Promise<{ ok: boolean; message: string }>
|
||||
discard: (paths: string[]) => Promise<void>
|
||||
}
|
||||
pty: {
|
||||
available: () => Promise<boolean>
|
||||
create: (kind: 'agent' | 'shell', cols: number, rows: number) => Promise<number>
|
||||
write: (id: number, data: string) => void
|
||||
resize: (id: number, cols: number, rows: number) => void
|
||||
kill: (id: number) => void
|
||||
onData: (cb: (id: number, data: string) => void) => () => void
|
||||
onExit: (cb: (id: number) => void) => () => void
|
||||
}
|
||||
config: {
|
||||
get: () => Promise<HelderConfig>
|
||||
theme: () => Promise<string>
|
||||
}
|
||||
recent: {
|
||||
get: () => Promise<string[]>
|
||||
set: (list: string[]) => Promise<void>
|
||||
}
|
||||
search: {
|
||||
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
||||
files: () => Promise<string[]>
|
||||
}
|
||||
symbols: {
|
||||
names: () => Promise<string[]>
|
||||
lookup: (name: string) => Promise<SymbolLookup>
|
||||
}
|
||||
dialog: {
|
||||
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
|
||||
}
|
||||
log: {
|
||||
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown) => void
|
||||
path: () => Promise<string | null>
|
||||
open: () => Promise<void>
|
||||
reveal: () => Promise<void>
|
||||
}
|
||||
onFullscreen: (cb: (on: boolean) => void) => () => void
|
||||
onProjectChanged: (cb: () => void) => () => void
|
||||
onConfigChanged: (cb: () => void) => () => void
|
||||
onRefresh: (cb: () => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
helder?: HelderBridge
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
53
src/renderer/src/error-boundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { rlog } from './log'
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
stack: string | null
|
||||
}
|
||||
|
||||
/** Catches render-time errors anywhere in the tree and shows a dark, recoverable
|
||||
* panel instead of a blank window. */
|
||||
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
|
||||
state: State = { error: null, stack: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error, stack: null }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// The component stack is the part that says WHICH panel blew up — it exists
|
||||
// only here, so it has to be logged now or it's gone.
|
||||
rlog.error('react', 'render error', error, { componentStack: info.componentStack })
|
||||
this.setState({ stack: info.componentStack ?? null })
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error, stack } = this.state
|
||||
if (!error) return this.props.children
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 14, background: 'var(--bg-0)', color: 'var(--fg-1)', fontFamily: 'var(--ui)', padding: 40, textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 15, color: 'var(--fg-0)' }}>Something went wrong</div>
|
||||
<pre style={{
|
||||
maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left',
|
||||
fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)',
|
||||
background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap',
|
||||
}}>{(error.stack || error.message) + (stack ? '\n' + stack : '')}</pre>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => location.reload()} style={{
|
||||
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
|
||||
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
|
||||
}}>Reload</button>
|
||||
{/* The panel shows this one error; the log has what led up to it. */}
|
||||
<button onClick={() => window.helder?.log.open()} style={{
|
||||
background: 'var(--bg-3)', color: 'var(--fg-1)', border: '1px solid var(--border-2)', borderRadius: 7,
|
||||
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
|
||||
}}>Open Log</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/fuzzy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Subsequence fuzzy match. Returns the matched character indices in `str`
|
||||
* (in order), or null when `q` is not a subsequence of `str`. Case-insensitive. */
|
||||
export function fuzzy(q: string, str: string): number[] | null {
|
||||
q = q.toLowerCase()
|
||||
const s = str.toLowerCase()
|
||||
let i = 0
|
||||
const idx: number[] = []
|
||||
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
|
||||
}
|
||||
155
src/renderer/src/highlight.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/* Syntax highlighting (Prism) + file-type icon metadata.
|
||||
*
|
||||
* The default `prismjs` bundle already registers markup, css, clike and
|
||||
* javascript. We add the rest in dependency order. CRITICAL: prism-php requires
|
||||
* prism-markup-templating to be loaded FIRST, or every Prism.highlight() call
|
||||
* throws and silently falls back to plain text. */
|
||||
import Prism from 'prismjs'
|
||||
import { isKnownSymbol } from './symbols'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-python'
|
||||
import 'prismjs/components/prism-typescript'
|
||||
import 'prismjs/components/prism-jsx'
|
||||
import 'prismjs/components/prism-tsx'
|
||||
import 'prismjs/components/prism-json'
|
||||
import 'prismjs/components/prism-bash'
|
||||
import 'prismjs/components/prism-yaml'
|
||||
import 'prismjs/components/prism-markdown'
|
||||
|
||||
// We drive highlighting manually; no DOM auto-scan.
|
||||
Prism.manual = true
|
||||
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
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: string): string {
|
||||
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() : ''
|
||||
}
|
||||
|
||||
// Files the viewer renders as a picture (<img>) rather than as text/code.
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng', 'jfif'])
|
||||
function isImage(path: string): boolean {
|
||||
return IMAGE_EXT.has(ext(path))
|
||||
}
|
||||
|
||||
function langFor(path: string): string | null {
|
||||
return EXT_LANG[ext(path)] || null
|
||||
}
|
||||
|
||||
function langLabel(path: string): string {
|
||||
const e = ext(path)
|
||||
const map: Record<string, string> = {
|
||||
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: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c] as string))
|
||||
}
|
||||
|
||||
/* Mark the class tokens this project actually declares, so ⌘-click has
|
||||
* something honest to underline. Prism already tells us which tokens are class
|
||||
* names; the index tells us which of those we can open. A token whose text is
|
||||
* not a bare identifier (a namespaced `App\Models\Agent`) carries nested spans
|
||||
* and never matches, which is right — the name to click sits inside it. */
|
||||
const TOKEN_RE = /<span class="token ([^"]*)">([A-Za-z_]\w*)<\/span>/g
|
||||
function markKnown(html: string): string {
|
||||
return html.replace(TOKEN_RE, (whole, cls: string, name: string) => (
|
||||
/\b(class-name|package)\b/.test(cls) && isKnownSymbol(name)
|
||||
? `<span class="token ${cls} sym">${name}</span>`
|
||||
: whole
|
||||
))
|
||||
}
|
||||
|
||||
// highlight a single line independently (keeps line numbering robust)
|
||||
function hlLine(line: string, lang: string | null): string {
|
||||
if (line === '') return ' '
|
||||
try {
|
||||
const grammar = lang ? Prism.languages[lang] : null
|
||||
if (grammar) {
|
||||
const html = Prism.highlight(line, grammar, lang as string)
|
||||
return lang === 'php' ? markKnown(html) : html
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return escapeHtml(line)
|
||||
}
|
||||
|
||||
// highlight a whole multi-line block (for the editable buffer's display layer)
|
||||
function hlText(text: string, lang: string | null): string {
|
||||
try {
|
||||
const grammar = lang ? Prism.languages[lang] : null
|
||||
if (grammar) {
|
||||
const html = Prism.highlight(text, grammar, lang as string)
|
||||
return lang === 'php' ? markKnown(html) : html
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return escapeHtml(text)
|
||||
}
|
||||
|
||||
// ---- file-type icon: colored monogram chip --------------------------
|
||||
interface IconMeta { c: string; t: string }
|
||||
|
||||
// Four fills only, taken from the syntax palette: purple, amber-soft, teal and
|
||||
// the neutral grey. The text is the extension, in ink on the fill.
|
||||
const PURPLE = '#C3A6CE'
|
||||
const AMBER = '#F0B476'
|
||||
const TEAL = '#8FBFB4'
|
||||
const GREY = '#BAC0C0'
|
||||
|
||||
const ICONS: Record<string, IconMeta> = {
|
||||
php: { c: PURPLE, t: 'php' },
|
||||
blade: { c: PURPLE, t: 'php' },
|
||||
ts: { c: PURPLE, t: 'ts' },
|
||||
tsx: { c: PURPLE, t: 'tsx' },
|
||||
vue: { c: PURPLE, t: 'vue' },
|
||||
js: { c: AMBER, t: 'js' },
|
||||
mjs: { c: AMBER, t: 'js' },
|
||||
cjs: { c: AMBER, t: 'js' },
|
||||
jsx: { c: AMBER, t: 'jsx' },
|
||||
json: { c: AMBER, t: 'json' },
|
||||
yml: { c: AMBER, t: 'yml' },
|
||||
yaml: { c: AMBER, t: 'yml' },
|
||||
css: { c: TEAL, t: 'css' },
|
||||
scss: { c: TEAL, t: 'scss' },
|
||||
pcss: { c: TEAL, t: 'css' },
|
||||
html: { c: TEAL, t: 'html' },
|
||||
sh: { c: TEAL, t: 'sh' },
|
||||
env: { c: TEAL, t: 'env' },
|
||||
py: { c: TEAL, t: 'py' },
|
||||
sql: { c: TEAL, t: 'sql' },
|
||||
md: { c: GREY, t: 'md' },
|
||||
txt: { c: GREY, t: 'txt' },
|
||||
lock: { c: GREY, t: 'lock' },
|
||||
svg: { c: GREY, t: 'svg' },
|
||||
}
|
||||
|
||||
const NAME_ICONS: Record<string, IconMeta> = {
|
||||
'composer.json': { c: PURPLE, t: 'json' },
|
||||
'package.json': { c: AMBER, t: 'json' },
|
||||
'.env': { c: TEAL, t: 'env' },
|
||||
}
|
||||
|
||||
function iconFor(path: string): IconMeta {
|
||||
const base = path.split('/').pop() || ''
|
||||
if (NAME_ICONS[base]) return NAME_ICONS[base]
|
||||
return ICONS[ext(path)] || { c: GREY, t: ext(path).slice(0, 4) || '·' }
|
||||
}
|
||||
|
||||
export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }
|
||||
72
src/renderer/src/launcher.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Project launcher — shown when Helder starts without a project (Spotlight / bare
|
||||
* launch). Lists the recent projects (max 20, newest first); the first row opens a
|
||||
* folder picker for a new project. Keyboard: ⌘↑/⌘↓ (or plain arrows) move the
|
||||
* selection, ↵ opens it — same model as the recent-files navigator. */
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './components'
|
||||
import type { RecentProject } from './project'
|
||||
|
||||
export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
|
||||
recents: RecentProject[]
|
||||
onOpenNew: () => void
|
||||
onOpenPath: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
const [sel, setSel] = useState(0)
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// rows = [new project, ...recents]; total selectable count
|
||||
const count = recents.length + 1
|
||||
|
||||
function activate(i: number): void {
|
||||
if (i <= 0) onOpenNew()
|
||||
else if (recents[i - 1]) onOpenPath(recents[i - 1].path)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, count - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); activate(selRef.current) }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [count, recents])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.lp-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="launcher">
|
||||
<div className="launcher-drag" />
|
||||
<div className="launcher-card">
|
||||
<div className="lp-head">
|
||||
<span className="lp-mark">{Icon.spark({ width: 16, height: 16 })}</span>
|
||||
<div className="lp-title"><b>helder<i>.</i></b><span>Open a project to begin</span></div>
|
||||
</div>
|
||||
<div className="lp-list" ref={listRef}>
|
||||
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
|
||||
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
|
||||
<span className="lp-ic">{Icon.plus()}</span>
|
||||
<div className="lp-txt"><span className="lp-name">Open new project…</span></div>
|
||||
<span className="esc-chip">↵</span>
|
||||
</div>
|
||||
{recents.length > 0 && <div className="lp-sec">Recent</div>}
|
||||
{recents.map((p, i) => {
|
||||
const idx = i + 1
|
||||
return (
|
||||
<div key={p.path} className={'lp-row' + (sel === idx ? ' sel' : '')} title={p.path}
|
||||
onMouseEnter={() => setSel(idx)} onClick={() => activate(idx)}>
|
||||
<span className="lp-ic">{Icon.reveal()}</span>
|
||||
<div className="lp-txt"><span className="lp-name">{p.name}</span><span className="lp-path">{p.path}</span></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="lp-foot"><b>⌘↑↓</b> navigate · <b>↵</b> open</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
91
src/renderer/src/log.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Renderer → main log bridge. Everything here ends up in the SAME file the main
|
||||
* process writes, so a crash reads as one chronological story ("git:load failed
|
||||
* … then the render process went OOM") instead of two disconnected halves.
|
||||
*
|
||||
* Without this, a renderer exception only ever reached DevTools — which nobody
|
||||
* has open at the moment things actually break.
|
||||
*/
|
||||
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
interface ErrLike { message: string; stack?: string; name?: string }
|
||||
|
||||
/** Structured-clone-safe: an Error survives IPC as `{}` unless unpacked here. */
|
||||
function pack(e: unknown): ErrLike | { value: string } {
|
||||
if (e instanceof Error) {
|
||||
const out: ErrLike = { message: e.message, name: e.name }
|
||||
if (e.stack) out.stack = e.stack
|
||||
return out
|
||||
}
|
||||
if (typeof e === 'string') return { value: e }
|
||||
try { return { value: JSON.stringify(e) ?? String(e) } } catch { return { value: String(e) } }
|
||||
}
|
||||
|
||||
function send(level: Level, scope: string, msg: string, ctx?: unknown): void {
|
||||
const bridge = window.helder
|
||||
// No bridge = browser preview (or a broken preload). Console is all we have.
|
||||
if (!bridge?.log) {
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||
fn(`[helder] ${scope}: ${msg}`, ctx ?? '')
|
||||
return
|
||||
}
|
||||
try { bridge.log.write(level, scope, msg, ctx) } catch { /* never let logging throw */ }
|
||||
}
|
||||
|
||||
export const rlog = {
|
||||
debug: (scope: string, msg: string, ctx?: unknown): void => send('debug', scope, msg, ctx),
|
||||
info: (scope: string, msg: string, ctx?: unknown): void => send('info', scope, msg, ctx),
|
||||
warn: (scope: string, msg: string, ctx?: unknown): void => send('warn', scope, msg, ctx),
|
||||
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||
send('error', scope, msg, err === undefined ? ctx : { ...ctx, err: pack(err) }),
|
||||
}
|
||||
|
||||
let installed = false
|
||||
let sawRoLoop = false
|
||||
|
||||
/* "ResizeObserver loop completed with undelivered notifications" is a browser
|
||||
* notice, not an exception: some observer resized its own target, so the layout
|
||||
* needed a second pass. It fires once per frame, so a window drag alone can bury
|
||||
* a real failure under a hundred lines. Keep the first one — it still points at
|
||||
* a layout loop worth fixing — and drop the repeats. */
|
||||
function isResizeObserverLoop(message: string): boolean {
|
||||
return message.startsWith('ResizeObserver loop')
|
||||
}
|
||||
|
||||
/** Hook the renderer's global failure paths. Call once, as early as possible. */
|
||||
export function installErrorLogging(): void {
|
||||
if (installed) return
|
||||
installed = true
|
||||
|
||||
// Uncaught throws outside React's render phase: event handlers, timers, and
|
||||
// the async IPC callbacks that make up most of this app.
|
||||
window.addEventListener('error', (e) => {
|
||||
// Resource load failures (a missing font/image) arrive here with no `error`
|
||||
// and target the element — worth a line, but they aren't exceptions.
|
||||
if (e.error === undefined && e.target && e.target !== window) {
|
||||
const el = e.target as HTMLElement & { src?: string; href?: string }
|
||||
rlog.warn('resource', `failed to load ${el.tagName?.toLowerCase?.() ?? 'resource'}`, { url: el.src || el.href || '' })
|
||||
return
|
||||
}
|
||||
if (isResizeObserverLoop(e.message || '')) {
|
||||
if (sawRoLoop) return
|
||||
sawRoLoop = true
|
||||
rlog.warn('renderer', e.message, { note: 'browser layout notice; repeats suppressed' })
|
||||
return
|
||||
}
|
||||
rlog.error('renderer', e.message || 'uncaught error', e.error, { source: e.filename, line: e.lineno, col: e.colno })
|
||||
}, true)
|
||||
|
||||
// The one that matters most here: every window.helder.* call is a promise, so
|
||||
// a rejected IPC with no .catch() lands here and nowhere else.
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
rlog.error('renderer', 'unhandled promise rejection', e.reason)
|
||||
})
|
||||
|
||||
rlog.info('renderer', 'window loaded', {
|
||||
url: location.href,
|
||||
bridge: !!window.helder,
|
||||
screen: `${window.innerWidth}x${window.innerHeight}`,
|
||||
})
|
||||
}
|
||||
36
src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
// Bundled locally (no Google Fonts CDN in Electron). Weights used by the UI.
|
||||
import '@fontsource/ibm-plex-mono/400.css'
|
||||
import '@fontsource/ibm-plex-mono/500.css'
|
||||
import '@fontsource/ibm-plex-mono/600.css'
|
||||
import '@fontsource/ibm-plex-mono/700.css'
|
||||
import '@fontsource/ibm-plex-mono/400-italic.css'
|
||||
import '@fontsource/ibm-plex-sans/400.css'
|
||||
import '@fontsource/ibm-plex-sans/500.css'
|
||||
import '@fontsource/ibm-plex-sans/600.css'
|
||||
import '@fontsource/ibm-plex-sans/700.css'
|
||||
import '@fontsource/ibm-plex-sans/400-italic.css'
|
||||
|
||||
// Initialises Prism + all grammars (correct php load order) as a side effect.
|
||||
import './highlight'
|
||||
import './styles.css'
|
||||
|
||||
import { App } from './App'
|
||||
import { ProjectProvider } from './project'
|
||||
import { ErrorBoundary } from './error-boundary'
|
||||
import { installErrorLogging } from './log'
|
||||
|
||||
// Before the first render, so an exception during mount is already captured.
|
||||
installErrorLogging()
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
221
src/renderer/src/markdown.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/* Minimal, self-contained Markdown → HTML renderer for the file viewer's
|
||||
* "Preview" mode. A block-level line parser plus an inline pass. All text is
|
||||
* HTML-escaped; only a safe subset of inline tags is emitted (no raw HTML
|
||||
* passthrough). Fenced code blocks are highlighted with Prism via HL. */
|
||||
import { HL } from './highlight'
|
||||
|
||||
/** Fence info-string → Prism language id (HL.hlText expects an id, not an alias). */
|
||||
const FENCE_LANG: Record<string, string> = {
|
||||
php: 'php', js: 'javascript', javascript: 'javascript', mjs: 'javascript',
|
||||
jsx: 'jsx', ts: 'typescript', typescript: 'typescript', tsx: 'tsx',
|
||||
py: 'python', python: 'python', html: 'markup', xml: 'markup', vue: 'markup',
|
||||
css: 'css', scss: 'css', json: 'json', sh: 'bash', bash: 'bash', shell: 'bash',
|
||||
yml: 'yaml', yaml: 'yaml', md: 'markdown', markdown: 'markdown',
|
||||
}
|
||||
|
||||
// Sentinel wrapping protected code-span placeholders. A private-use code point
|
||||
// that never occurs in real markdown and isn't touched by escapeHtml, so it
|
||||
// can't collide with prose (a plain " 5 " would) nor trip control-char rules.
|
||||
const SENT = ''
|
||||
const SENT_RE = /(\d+)/g
|
||||
|
||||
/** Allow http(s), mailto, in-page anchors and relative paths; drop anything
|
||||
* else (e.g. `javascript:`) so a previewed file can't smuggle a live URL. */
|
||||
function safeUrl(url: string): string {
|
||||
const u = url.trim()
|
||||
if (/^(https?:|mailto:|#|\.?\.?\/)/i.test(u)) return u
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(u)) return '' // some other scheme → drop
|
||||
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. */
|
||||
function inline(src: string, baseDir: string): string {
|
||||
// Pull code spans out first so their literal content is never re-processed.
|
||||
const codes: string[] = []
|
||||
let s = src.replace(/`([^`]+)`/g, (_m, c) => {
|
||||
codes.push('<code>' + HL.escapeHtml(c) + '</code>')
|
||||
return SENT + (codes.length - 1) + SENT
|
||||
})
|
||||
s = HL.escapeHtml(s)
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, (_m, alt, raw) => {
|
||||
const { url, title } = dest(raw)
|
||||
const u = safeUrl(url)
|
||||
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, text, raw) => {
|
||||
const { url, title } = dest(raw)
|
||||
const u = safeUrl(url)
|
||||
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, '<em>$1</em>')
|
||||
s = s.replace(/(^|[^a-zA-Z0-9_])_([^_]+)_(?=[^a-zA-Z0-9_]|$)/g, '$1<em>$2</em>')
|
||||
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
// Restore the protected code spans.
|
||||
return s.replace(SENT_RE, (_m, i) => codes[+i])
|
||||
}
|
||||
|
||||
/** Split one GFM table row into cells. A `\|` is a literal pipe, not a divider. */
|
||||
function splitRow(line: string): string[] {
|
||||
const s = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|\s*$/, '')
|
||||
const cells: string[] = []
|
||||
let cur = ''
|
||||
for (let j = 0; j < s.length; j++) {
|
||||
if (s[j] === '\\' && s[j + 1] === '|') { cur += '|'; j++; continue }
|
||||
if (s[j] === '|') { cells.push(cur); cur = ''; continue }
|
||||
cur += s[j]
|
||||
}
|
||||
cells.push(cur)
|
||||
return cells.map((c) => c.trim())
|
||||
}
|
||||
|
||||
/** The `---`/`:---:` row under a table header. Also fixes each column's align. */
|
||||
function tableAligns(line: string): (string | null)[] | null {
|
||||
if (!line.includes('|') && !/^\s*:?-+:?\s*$/.test(line)) return null
|
||||
const cells = splitRow(line)
|
||||
if (!cells.length) return null
|
||||
const aligns: (string | null)[] = []
|
||||
for (const c of cells) {
|
||||
if (!/^:?-{1,}:?$/.test(c)) return null
|
||||
const left = c.startsWith(':'), right = c.endsWith(':')
|
||||
aligns.push(left && right ? 'center' : right ? 'right' : left ? 'left' : null)
|
||||
}
|
||||
return aligns
|
||||
}
|
||||
|
||||
/** One `<td>`/`<th>`, with the column's alignment when the header set one. */
|
||||
function cell(tag: string, text: string, align: string | null, baseDir: string): string {
|
||||
const a = align ? ` style="text-align:${align}"` : ''
|
||||
return `<${tag}${a}>` + inline(text, baseDir) + `</${tag}>`
|
||||
}
|
||||
|
||||
export function renderMarkdown(text: string, baseDir = ''): string {
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
const out: string[] = []
|
||||
let para: string[] = []
|
||||
const flushPara = (): void => {
|
||||
if (para.length) { out.push('<p>' + inline(para.join(' '), baseDir) + '</p>'); para = [] }
|
||||
}
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
|
||||
const fence = line.match(/^```\s*([\w+-]*)\s*$/)
|
||||
if (fence) {
|
||||
flushPara()
|
||||
const lang = FENCE_LANG[fence[1].toLowerCase()] || null
|
||||
const buf: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i])) { buf.push(lines[i]); i++ }
|
||||
i++ // skip closing fence
|
||||
const code = buf.join('\n')
|
||||
out.push('<pre class="md-code"><code>' + (lang ? HL.hlText(code, lang) : HL.escapeHtml(code)) + '</code></pre>')
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*$/.test(line)) { flushPara(); i++; continue }
|
||||
|
||||
const h = line.match(/^(#{1,6})\s+(.*)$/)
|
||||
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 }
|
||||
|
||||
// GFM table: a header row with pipes, then a `---|---` row with the same
|
||||
// column count. Body rows run until a blank line or a line without a pipe.
|
||||
if (line.includes('|') && i + 1 < lines.length) {
|
||||
const head = splitRow(line)
|
||||
const aligns = tableAligns(lines[i + 1])
|
||||
if (aligns && aligns.length === head.length) {
|
||||
flushPara()
|
||||
i += 2
|
||||
const rows: string[][] = []
|
||||
while (i < lines.length && lines[i].includes('|') && !/^\s*$/.test(lines[i])) {
|
||||
rows.push(splitRow(lines[i])); i++
|
||||
}
|
||||
const body = rows.map((r) => '<tr>' + head.map((_c, n) => cell('td', r[n] ?? '', aligns[n], baseDir)).join('') + '</tr>').join('')
|
||||
out.push(
|
||||
'<table class="md-table"><thead><tr>' +
|
||||
head.map((c, n) => cell('th', c, aligns[n], baseDir)).join('') +
|
||||
'</tr></thead>' + (body ? '<tbody>' + body + '</tbody>' : '') + '</table>',
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (/^\s*>/.test(line)) {
|
||||
flushPara()
|
||||
const buf: string[] = []
|
||||
while (i < lines.length && /^\s*>/.test(lines[i])) { buf.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
|
||||
out.push('<blockquote>' + renderMarkdown(buf.join('\n'), baseDir) + '</blockquote>')
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*[-*+]\s+/.test(line)) {
|
||||
flushPara()
|
||||
const items: string[] = []
|
||||
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, baseDir) + '</li>').join('') + '</ul>')
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*\d+[.)]\s+/.test(line)) {
|
||||
flushPara()
|
||||
const items: string[] = []
|
||||
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, baseDir) + '</li>').join('') + '</ol>')
|
||||
continue
|
||||
}
|
||||
|
||||
para.push(line.trim())
|
||||
i++
|
||||
}
|
||||
flushPara()
|
||||
return out.join('\n')
|
||||
}
|
||||
921
src/renderer/src/overlays.tsx
Normal file
@@ -0,0 +1,921 @@
|
||||
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useProject } from './project'
|
||||
import type { RecentProject } from './project'
|
||||
import { fuzzy } from './fuzzy'
|
||||
import { isKnownSymbol } from './symbols'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import { HL } from './highlight'
|
||||
import type { OpenFile } from './components'
|
||||
import type { SymbolDef, SymbolLookup } from './types'
|
||||
|
||||
export interface MenuItem {
|
||||
sep?: boolean
|
||||
primary?: boolean
|
||||
/** Destructive item (delete, discard). Reads in amber-deep. */
|
||||
danger?: boolean
|
||||
icon?: React.ReactElement
|
||||
label?: string
|
||||
kbd?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
|
||||
export interface Toast { id: number; title: string; ref?: string }
|
||||
|
||||
/** Build the exact text inserted into the agent for a "Pass on …" action.
|
||||
* - Plain reference / name → `note => thing` (or just `thing` with no note).
|
||||
* - Selected code → `note ref` followed by the code in a fenced block. */
|
||||
export function buildPass(note: string, ref: string, code?: string): string {
|
||||
const n = note.trim()
|
||||
if (code != null) {
|
||||
const head = (n ? n + ' ' : '') + ref
|
||||
return head + '\n```\n' + code + '\n```'
|
||||
}
|
||||
return n ? `${n} => ${ref}` : ref
|
||||
}
|
||||
interface ContentHit { no: number; ln: string; ix: number }
|
||||
interface ContentGroup { path: string; hits: ContentHit[] }
|
||||
|
||||
function Highlight({ text, idx }: { text: string; idx: number[] | null }): React.ReactElement {
|
||||
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> : <Fragment key={i}>{ch}</Fragment>)}</span>
|
||||
}
|
||||
|
||||
/** A path is hidden if any of its segments is a dotfile/dotfolder (e.g. `.env`,
|
||||
* `src/.cache/x`). Used to exclude hidden entries from search when the toggle is
|
||||
* off — mirrors the Explorer tree filter. */
|
||||
function isHiddenPath(p: string): boolean {
|
||||
return p.split('/').some((seg) => seg.startsWith('.'))
|
||||
}
|
||||
|
||||
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet, activePath, activeText, showHidden }: {
|
||||
initialQuery?: string
|
||||
onOpen: OpenFile
|
||||
onOpenAt: (path: string, line: number) => void
|
||||
onClose: () => void
|
||||
changeSet: Set<string>
|
||||
activePath?: string | null
|
||||
activeText?: string
|
||||
showHidden?: boolean
|
||||
}): React.ReactElement {
|
||||
const PROJECT = useProject()
|
||||
const bridge = window.helder
|
||||
const [q, setQ] = useState(() => initialQuery ?? '')
|
||||
const [sel, setSel] = useState(0)
|
||||
const [fileSel, setFileSel] = useState(0)
|
||||
const [inFileSel, setInFileSel] = useState(0)
|
||||
const [extSel, setExtSel] = useState<string | null>(null) // null = every file type
|
||||
const hasInFile = !!activePath
|
||||
type Col = 'infile' | 'content' | 'files'
|
||||
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
||||
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
|
||||
// While the arrows drive, the parked pointer must stop painting its own row.
|
||||
const [kbdNav, setKbdNav] = useState(true)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inFileRef = useRef<HTMLDivElement>(null)
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// file-name list: ripgrep `--files` when available, else the in-memory index keys
|
||||
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
|
||||
useEffect(() => {
|
||||
if (inputRef.current) { inputRef.current.focus(); inputRef.current.select() } // select seed so typing replaces it
|
||||
if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
|
||||
}, [])
|
||||
|
||||
// content hits (left): ripgrep (debounced) when available, else in-memory substring grep
|
||||
const [content, setContent] = useState<ContentGroup[]>([])
|
||||
useEffect(() => {
|
||||
const term = q.trim()
|
||||
if (term.length < 2) { setContent([]); return }
|
||||
if (bridge) {
|
||||
let alive = true
|
||||
const t = setTimeout(() => {
|
||||
bridge.search.content(term).then((g) => { if (alive) setContent(g) }).catch(() => { if (alive) setContent([]) })
|
||||
}, 120)
|
||||
return () => { alive = false; clearTimeout(t) }
|
||||
}
|
||||
const low = term.toLowerCase()
|
||||
const groups: ContentGroup[] = []
|
||||
for (const [path, src] of Object.entries(PROJECT.files)) {
|
||||
const lines = src.split('\n')
|
||||
const hits: ContentHit[] = []
|
||||
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 })
|
||||
}
|
||||
setContent(groups)
|
||||
return
|
||||
}, [q])
|
||||
|
||||
// hide dotfile content hits unless the Hidden toggle is on
|
||||
const shownContent = useMemo(
|
||||
() => (showHidden ? content : content.filter((g) => !isHiddenPath(g.path))),
|
||||
[content, showHidden],
|
||||
)
|
||||
const visibleContent = useMemo(
|
||||
() => (extSel ? shownContent.filter((g) => HL.ext(g.path) === extSel) : shownContent),
|
||||
[shownContent, extSel],
|
||||
)
|
||||
|
||||
// in-file matches (leftmost): substring grep within the currently open file's buffer
|
||||
const inFile = useMemo(() => {
|
||||
const term = q.trim()
|
||||
if (!hasInFile || term.length < 1 || !activeText) return [] as ContentHit[]
|
||||
const low = term.toLowerCase()
|
||||
const hits: ContentHit[] = []
|
||||
activeText.split('\n').forEach((ln, i) => {
|
||||
const ix = ln.toLowerCase().indexOf(low)
|
||||
if (ix >= 0) hits.push({ no: i + 1, ln, ix })
|
||||
})
|
||||
return hits
|
||||
}, [q, activeText, hasInFile])
|
||||
|
||||
// file-name matches (right)
|
||||
const allFiles = useMemo(() => {
|
||||
const term = q.trim()
|
||||
if (!term) return []
|
||||
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
|
||||
for (const p of allPaths) {
|
||||
if (!showHidden && isHiddenPath(p)) continue
|
||||
const name = p.split('/').pop() as string
|
||||
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, allPaths, showHidden])
|
||||
const files = useMemo(
|
||||
() => (extSel ? allFiles.filter((r) => HL.ext(r.path) === extSel) : allFiles),
|
||||
[allFiles, extSel],
|
||||
)
|
||||
|
||||
// file-type chips: every extension the unfiltered results carry, most matches first
|
||||
const extList = useMemo(() => {
|
||||
const n = new Map<string, number>()
|
||||
const add = (path: string): void => {
|
||||
const e = HL.ext(path)
|
||||
if (e) n.set(e, (n.get(e) ?? 0) + 1)
|
||||
}
|
||||
shownContent.forEach((g) => add(g.path))
|
||||
allFiles.forEach((r) => add(r.path))
|
||||
return [...n].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 12)
|
||||
}, [shownContent, allFiles])
|
||||
// a chosen type that the new results no longer carry falls back to "all types"
|
||||
useEffect(() => {
|
||||
if (extSel && !extList.some(([e]) => e === extSel)) setExtSel(null)
|
||||
}, [extList, extSel])
|
||||
|
||||
// flat list of content hits for keyboard nav
|
||||
const flat = useMemo(() => {
|
||||
const arr: { path: string; no: number }[] = []
|
||||
visibleContent.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })))
|
||||
return arr
|
||||
}, [visibleContent])
|
||||
|
||||
const totalHits = flat.length
|
||||
const fileCount = Math.min(files.length, 40)
|
||||
const inFileCount = Math.min(inFile.length, 200)
|
||||
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q, extSel])
|
||||
useEffect(() => {
|
||||
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
useEffect(() => {
|
||||
const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [fileSel])
|
||||
useEffect(() => {
|
||||
const el = inFileRef.current && inFileRef.current.querySelector('.sr-line.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [inFileSel])
|
||||
|
||||
function onKey(e: React.KeyboardEvent): void {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
e.preventDefault()
|
||||
const i = Math.max(0, cols.indexOf(col))
|
||||
const ni = e.key === 'ArrowLeft' ? Math.max(0, i - 1) : Math.min(cols.length - 1, i + 1)
|
||||
setCol(cols[ni])
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setKbdNav(true)
|
||||
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
|
||||
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
|
||||
else setSel((s) => Math.min(s + 1, flat.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setKbdNav(true)
|
||||
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
|
||||
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
|
||||
else setSel((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (col === 'infile') {
|
||||
if (activePath && inFile[inFileSel]) { onOpenAt(activePath, inFile[inFileSel].no); onClose() }
|
||||
} else if (col === 'files') {
|
||||
if (files[fileSel]) { onOpen(files[fileSel].path); onClose() }
|
||||
else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
|
||||
} else {
|
||||
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: string, ix: number, len: number): React.ReactElement {
|
||||
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 this file, the project, and file names…" spellCheck={false} />
|
||||
{extList.length > 1 && (
|
||||
<div className="ext-filters">
|
||||
{extList.map(([e, n]) => (
|
||||
<button key={e} className={'ext-chip' + (extSel === null || extSel === e ? ' on' : '')}
|
||||
title={`${n} result${n === 1 ? '' : 's'} · ${extSel === e ? 'click to show every type' : 'click to show only this type'}`}
|
||||
onMouseDown={(ev) => ev.preventDefault()}
|
||||
onClick={() => setExtSel((cur) => (cur === e ? null : e))}>{e}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className={'search-cols' + (kbdNav ? ' kbd-nav' : '')} onMouseMove={() => setKbdNav(false)}>
|
||||
{hasInFile && (
|
||||
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
|
||||
<div className="sc-head">
|
||||
<FileIcon path={activePath as string} />
|
||||
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
|
||||
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
|
||||
</div>
|
||||
{term.length < 1 && <div className="pempty sm">Type to search this file.</div>}
|
||||
{term.length >= 1 && inFile.length === 0 && <div className="pempty sm">No matches in this file.</div>}
|
||||
{inFile.slice(0, 200).map((h, i) => (
|
||||
<div key={h.no} className={'sr-line' + (i === inFileSel && col === 'infile' ? ' sel' : '')}
|
||||
onMouseEnter={() => { setCol('infile'); setInFileSel(i) }}
|
||||
onClick={() => { if (activePath) { onOpenAt(activePath, h.no); onClose() } }}>
|
||||
<span className="no">{h.no}</span>
|
||||
{renderLine(h.ln, h.ix, term.length)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={'sc-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
|
||||
<div className="sc-head">Project {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
||||
{term.length < 2 && <div className="pempty">Type at least two characters.</div>}
|
||||
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches.</div>}
|
||||
{visibleContent.map((g) => (
|
||||
<Fragment key={g.path}>
|
||||
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
||||
<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 && col === 'content' ? ' sel' : '')}
|
||||
onMouseEnter={() => { setCol('content'); setSel(me) }}
|
||||
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
||||
<span className="no">{h.no}</span>
|
||||
{renderLine(h.ln, h.ix, term.length)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
|
||||
<div className="sc-head">Files <span className="sc-ct">{files.length}</span></div>
|
||||
{!term && <div className="pempty sm">Type to search file names.</div>}
|
||||
{term && files.length === 0 && <div className="pempty sm">No file names match.</div>}
|
||||
{files.slice(0, 40).map((r, i) => {
|
||||
const name = r.path.split('/').pop() as string
|
||||
const dir = r.path.split('/').slice(0, -1).join('/')
|
||||
return (
|
||||
<div key={r.path} className={'fres' + (col === 'files' && i === fileSel ? ' sel' : '')}
|
||||
onMouseEnter={() => { setCol('files'); setFileSel(i) }}
|
||||
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 className="ov-foot">
|
||||
<span><b>⌘←</b> / <b>⌘→</b> column</span>
|
||||
<span><b>⌘↑</b> / <b>⌘↓</b> row</span>
|
||||
<span><b>↵</b> open</span>
|
||||
<span className="ov-foot-spacer" />
|
||||
<span className="esc-chip">esc</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Recently-opened-files navigator. Looks like the search modal but is a single
|
||||
* keyboard-driven list (most-recent first). ⌘↓/⌘↑ move the selection, ↵ opens.
|
||||
* Listens in the capture phase so it owns the keyboard while open. */
|
||||
export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }: {
|
||||
history: string[]
|
||||
initialSel: number
|
||||
onOpen: OpenFile
|
||||
onClose: () => void
|
||||
changeSet: Set<string>
|
||||
}): React.ReactElement {
|
||||
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// While the keyboard drives, the pointer sits parked on whatever row it last
|
||||
// touched and CSS :hover would keep painting it beside the selected row. The
|
||||
// class tells the list to ignore hover until the mouse actually moves again.
|
||||
const [kbdNav, setKbdNav] = useState(true)
|
||||
function onMouseMove(e: React.MouseEvent): void {
|
||||
setKbdNav(false)
|
||||
const row = (e.target as HTMLElement).closest?.('.hist-row') as HTMLElement | null
|
||||
const i = row?.dataset.i
|
||||
if (i != null) setSel(+i)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.min(s + 1, history.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setKbdNav(true); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [history])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.file({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Recent files</span>
|
||||
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||
</div>
|
||||
<div className={'hist-list' + (kbdNav ? ' kbd-nav' : '')} ref={listRef} onMouseMove={onMouseMove}>
|
||||
{history.length === 0 && <div className="pempty">No files opened yet</div>}
|
||||
{history.map((p, i) => {
|
||||
const name = p.split('/').pop() as string
|
||||
const dir = p.split('/').slice(0, -1).join('/')
|
||||
return (
|
||||
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p} data-i={i}
|
||||
onClick={() => { onOpen(p); onClose() }}>
|
||||
<FileIcon path={p} />
|
||||
<div className="hist-txt">
|
||||
<span className="fn">{name}</span>
|
||||
<span className="fd">{dir ? dir + '/' : ''}</span>
|
||||
</div>
|
||||
{changeSet.has(p) && <span className="tree-badge M" style={{ fontFamily: 'var(--mono)', fontSize: 10 }}>•</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Project history picker (⇧⌘O). Same keyboard model as the launcher and the
|
||||
* recent-files navigator: ⌘↑/⌘↓ (or plain arrows) move, ↵ switches the current
|
||||
* window to that project, esc closes. The currently-open project is filtered
|
||||
* out — reopening it is a no-op. */
|
||||
export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
|
||||
recents: RecentProject[]
|
||||
currentRoot: string | null
|
||||
onOpen: (path: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const list = useMemo(() => recents.filter((p) => p.path !== currentRoot), [recents, currentRoot])
|
||||
const [sel, setSel] = useState(0)
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, list.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const p = list[selRef.current]; if (p) { onOpen(p.path); onClose() } }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [list, onOpen, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.reveal({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Open recent project</span>
|
||||
<span className="mode-chip">{list.length} project{list.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||
</div>
|
||||
<div className="hist-list" ref={listRef}>
|
||||
{list.length === 0 && <div className="pempty">No other recent projects</div>}
|
||||
{list.map((p, i) => (
|
||||
<div key={p.path} className={'hist-row' + (i === sel ? ' sel' : '')} title={p.path}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => { onOpen(p.path); onClose() }}>
|
||||
{Icon.reveal()}
|
||||
<div className="hist-txt">
|
||||
<span className="fn">{p.name}</span>
|
||||
<span className="fd">{p.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */
|
||||
const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[] = [
|
||||
{
|
||||
group: 'Navigate',
|
||||
rows: [
|
||||
{ keys: ['⌘', 'F'], label: 'Search contents and names' },
|
||||
{ keys: ['⌘', '↑↓'], label: 'Move through a list' },
|
||||
{ keys: ['↵'], label: 'Open the selected item' },
|
||||
{ keys: ['⌘', '←→'], label: 'Column left or right' },
|
||||
{ keys: ['↑↓'], label: 'Git or Explorer: move the row cursor' },
|
||||
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project' },
|
||||
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Agent',
|
||||
rows: [
|
||||
{ keys: ['⌘', '→'], label: 'Pass the selection to the agent' },
|
||||
{ keys: ['⌘', 'N'], label: 'Open the project note' },
|
||||
{ keys: ['⌘', 'P'], label: 'Pass on the whole note' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Git',
|
||||
rows: [
|
||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
||||
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
|
||||
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff' },
|
||||
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'File',
|
||||
rows: [
|
||||
{ keys: ['⌘', 'S'], label: 'Save this file' },
|
||||
{ keys: ['⌘', '.'], label: 'Hidden files 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.help()}
|
||||
<span className="hist-title">Keyboard shortcuts</span>
|
||||
<span className="esc-chip">esc</span>
|
||||
</div>
|
||||
<div className="help-list">
|
||||
{SHORTCUTS.map((g) => (
|
||||
<Fragment key={g.group}>
|
||||
<div className="help-sec">{g.group}</div>
|
||||
{g.rows.map((r, i) => (
|
||||
<div key={i} className="help-row">
|
||||
<span className="help-keys">{r.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
|
||||
<span className="help-label">{r.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scratch note for the project, stored as plain text in `.notes.txt`.
|
||||
*
|
||||
* The overlay only edits the text. Saving is the App's job, because the note
|
||||
* must also be written when the window loses focus with the overlay shut.
|
||||
* ⌘P (pass the note to the agent) is the App's job too — it owns the shortcut.
|
||||
*/
|
||||
export function NotesModal({ text, onChange, onClose }: {
|
||||
text: string
|
||||
onChange: (text: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
// Caret at the end, so you carry on writing instead of overtyping.
|
||||
el.setSelectionRange(el.value.length, el.value.length)
|
||||
}, [])
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.note()}
|
||||
<span className="hist-title">Note</span>
|
||||
<span className="notes-file">.notes.txt</span>
|
||||
<span className="notes-hint">To the agent <kbd>⌘P</kbd></span>
|
||||
<span className="esc-chip">esc</span>
|
||||
</div>
|
||||
<textarea ref={ref} className="notes-input" spellCheck={false}
|
||||
placeholder="Anything you want to keep next to this project…"
|
||||
value={text} onChange={(e) => onChange(e.target.value)} />
|
||||
<div className="notes-foot">saved on focus loss · {text.length} characters</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
|
||||
* it owns the keyboard while open. */
|
||||
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
|
||||
title: string
|
||||
body?: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onConfirm(); onClose() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [])
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="confirm-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cf-title">{title}</div>
|
||||
{body && <div className="cf-body">{body}</div>}
|
||||
<div className="cf-actions">
|
||||
<button className="cf-btn" onClick={onClose}>Cancel <kbd>esc</kbd></button>
|
||||
<button className={'cf-btn cf-yes' + (danger ? ' danger' : '')} onClick={() => { onConfirm(); onClose() }} autoFocus>
|
||||
{confirmLabel ?? 'Confirm'} <kbd>↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Index of the first selectable (non-separator) item, for keyboard highlight.
|
||||
const items = menu?.items ?? []
|
||||
const firstSel = items.findIndex((it) => !it.sep)
|
||||
const [hi, setHi] = useState(firstSel)
|
||||
const hiRef = useRef(hi); hiRef.current = hi
|
||||
// Reset the highlight to the first selectable item whenever the menu reopens
|
||||
// (right-click can swap the target without unmounting this component).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setHi(items.findIndex((it) => !it.sep)) }, [menu])
|
||||
// Step the highlight to the next/previous selectable item, skipping separators.
|
||||
function step(dir: 1 | -1): void {
|
||||
setHi((cur) => {
|
||||
let i = cur
|
||||
for (let n = 0; n < items.length; n++) {
|
||||
i = (i + dir + items.length) % items.length
|
||||
if (!items[i].sep) return i
|
||||
}
|
||||
return cur
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
|
||||
const k = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); step(1) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); step(-1) }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const it = items[hiRef.current]
|
||||
if (it && !it.sep) { it.onClick?.(); onClose() }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', h)
|
||||
document.addEventListener('keydown', k, true)
|
||||
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length])
|
||||
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-menu" ref={ref} style={{ left: x, top: y }}>
|
||||
{menu.note && <div className="ctx-note">{menu.note}</div>}
|
||||
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
||||
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (it.danger ? ' danger' : '') + (i === hi ? ' hi' : '')}
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => { it.onClick?.(); onClose() }}>
|
||||
<span className="ic">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ⌘-click on a PHP class name: where it is declared, then every reference to
|
||||
* it. Both sections open a file at a line, so the whole popup is one flat list
|
||||
* for the keyboard: ↑↓ move, ↵ opens, Esc closes.
|
||||
*
|
||||
* Centred on the window rather than anchored to the click: the list is as long
|
||||
* as the class is popular, and a click near the bottom edge would cut it off. */
|
||||
export function SymbolPopup({ name, data, onOpenAt, onSymbol, onClose }: {
|
||||
name: string
|
||||
/** null while the lookup runs. */
|
||||
data: SymbolLookup | null
|
||||
onOpenAt: (path: string, line: number) => void
|
||||
/** Follow a parent class or interface without leaving the popup. */
|
||||
onSymbol: (name: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [hi, setHi] = useState(0)
|
||||
const targets = useMemo(() => {
|
||||
const out: { path: string; line: number }[] = []
|
||||
data?.defs.forEach((d) => out.push({ path: d.path, line: d.line }))
|
||||
data?.refs.forEach((g) => g.hits.forEach((h) => out.push({ path: g.path, line: h.no })))
|
||||
return out
|
||||
}, [data])
|
||||
const hiRef = useRef(hi); hiRef.current = hi
|
||||
const tRef = useRef(targets); tRef.current = targets
|
||||
|
||||
useEffect(() => { setHi(0) }, [data])
|
||||
useEffect(() => {
|
||||
const key = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.min(i + 1, tRef.current.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); setHi((i) => Math.max(i - 1, 0)) }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const t = tRef.current[hiRef.current]
|
||||
if (t) { onOpenAt(t.path, t.line); onClose() }
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', key, true)
|
||||
return () => { document.removeEventListener('keydown', key, true) }
|
||||
}, [onClose, onOpenAt])
|
||||
useEffect(() => {
|
||||
const el = ref.current?.querySelector('.sym-row.hi')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [hi])
|
||||
|
||||
// One declaration: name it in full, the way the file does. Several: the short
|
||||
// name is the only thing they share.
|
||||
const only = data && data.defs.length === 1 ? data.defs[0] : null
|
||||
const title = only && only.ns ? only.ns + '\\' + name : name
|
||||
let ix = -1
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="sym-popup" ref={ref} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="sym-head">
|
||||
<span className="sym-name">{title}</span>
|
||||
{data && <span className="sym-count">{data.refCount} reference{data.refCount === 1 ? '' : 's'}</span>}
|
||||
</div>
|
||||
{!data && <div className="pempty sm">Searching…</div>}
|
||||
{data && (
|
||||
<div className="sym-body">
|
||||
<div className="sym-sec">Defined in</div>
|
||||
{data.defs.length === 0 && <div className="pempty sm">No declaration in this project</div>}
|
||||
{data.defs.map((d) => {
|
||||
ix++
|
||||
const me = ix
|
||||
return (
|
||||
<div key={d.path + d.line} className={'sym-row def' + (me === hi ? ' hi' : '')}
|
||||
onMouseEnter={() => setHi(me)}
|
||||
onClick={() => { onOpenAt(d.path, d.line); onClose() }}>
|
||||
<FileIcon path={d.path} />
|
||||
<span className="sym-path">{d.path}</span>
|
||||
<span className="sym-kind">{d.kind}</span>
|
||||
<span className="no">{d.line}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{data.defs.map((d) => (
|
||||
<SymbolRelations key={'rel' + d.path + d.line} def={d} onSymbol={onSymbol} />
|
||||
))}
|
||||
{data.refs.length > 0 && <div className="sym-sec">Used in</div>}
|
||||
{data.refs.map((g) => (
|
||||
<Fragment key={g.path}>
|
||||
<div className="sr-file" onClick={() => { onOpenAt(g.path, g.hits[0].no); onClose() }}>
|
||||
<FileIcon path={g.path} />
|
||||
<span className="srf-name">{g.path}</span>
|
||||
<span className="cnt">{g.hits.length}</span>
|
||||
</div>
|
||||
{g.hits.map((h) => {
|
||||
ix++
|
||||
const me = ix
|
||||
return (
|
||||
<div key={h.no} className={'sym-row sr-line' + (me === hi ? ' hi sel' : '')}
|
||||
onMouseEnter={() => setHi(me)}
|
||||
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
||||
<span className="no">{h.no}</span>
|
||||
<span className="tx">{h.ln.slice(0, 200)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* What a declaration extends and implements, read off its own line. A parent
|
||||
* this project declares is a chip you can follow; a vendor or built-in parent
|
||||
* stays plain text, for the same reason a token only underlines when we can
|
||||
* actually open it. */
|
||||
function SymbolRelations({ def, onSymbol }: { def: SymbolDef; onSymbol: (name: string) => void }): React.ReactElement | null {
|
||||
// Read defensively: this object crossed IPC, and a main process from before
|
||||
// these fields existed (a dev session that did not restart, a cached index)
|
||||
// must degrade to "no relations", never take the window down.
|
||||
const parents = def.parents ?? []
|
||||
const interfaces = def.interfaces ?? []
|
||||
const traits = def.traits ?? []
|
||||
if (!parents.length && !interfaces.length && !traits.length) return null
|
||||
const chip = (n: string): React.ReactElement => (
|
||||
isKnownSymbol(n)
|
||||
? <button key={n} className="sym-chip known" onClick={() => onSymbol(n)}>{n}</button>
|
||||
: <span key={n} className="sym-chip">{n}</span>
|
||||
)
|
||||
return (
|
||||
<div className="sym-rel">
|
||||
{parents.length > 0 && (
|
||||
<div className="sym-rel-row"><span className="sym-rel-key">extends</span>{parents.map(chip)}</div>
|
||||
)}
|
||||
{interfaces.length > 0 && (
|
||||
<div className="sym-rel-row"><span className="sym-rel-key">implements</span>{interfaces.map(chip)}</div>
|
||||
)}
|
||||
{traits.length > 0 && (
|
||||
<div className="sym-rel-row"><span className="sym-rel-key">uses</span>{traits.map(chip)}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
|
||||
x: number
|
||||
y: number
|
||||
refStr: string
|
||||
code?: string
|
||||
onConfirm: (payload: string) => void
|
||||
onCancel: () => void
|
||||
}): React.ReactElement {
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => { if (inputRef.current) inputRef.current.focus() }, [])
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() }
|
||||
const k = (e: KeyboardEvent): void => { 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 payload = buildPass(text, refStr, code)
|
||||
return (
|
||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||
<div className="pass-head">{Icon.spark()}<span>Pass on to agent</span><span className="pass-esc">esc</span></div>
|
||||
<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(payload) }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
|
||||
}} />
|
||||
<div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
|
||||
<div className="pass-foot"><b>↵</b> insert into agent · <b>esc</b> cancel</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One small popup for naming things: creating a file/folder inside `dir`, or
|
||||
* renaming an existing entry (`mode="rename"`, prefilled with `initial`). A
|
||||
* rename stays in the same folder, so the input is always a bare basename.
|
||||
*/
|
||||
export function NamePopup({ x, y, dir, kind = 'file', mode = 'create', initial = '', onConfirm, onCancel }: {
|
||||
x: number
|
||||
y: number
|
||||
dir: string
|
||||
kind?: 'file' | 'folder'
|
||||
mode?: 'create' | 'rename'
|
||||
initial?: string
|
||||
onConfirm: (name: string) => void
|
||||
onCancel: () => void
|
||||
}): React.ReactElement {
|
||||
const isFolder = kind === 'folder'
|
||||
const isRename = mode === 'rename'
|
||||
const [name, setName] = useState(initial)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
// A rename opens with the name in place; select the stem only, so typing
|
||||
// replaces "notes" in "notes.md" and leaves the extension alone.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
if (!isRename || !initial) return
|
||||
const dot = initial.lastIndexOf('.')
|
||||
el.setSelectionRange(0, dot > 0 ? dot : initial.length)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() }
|
||||
const k = (e: KeyboardEvent): void => { 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 trimmed = name.trim()
|
||||
const target = (dir ? dir + '/' : '') + trimmed
|
||||
const noun = isFolder ? 'folder' : 'file'
|
||||
const title = isRename ? `Rename ${noun}` : (isFolder ? 'New folder' : 'New file')
|
||||
const ok = !!trimmed && !(isRename && trimmed === initial)
|
||||
return (
|
||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||
<div className="pass-head">{isFolder ? Icon.folder() : Icon.file()}<span>{title}</span><span className="pass-esc">esc</span></div>
|
||||
<input ref={inputRef} className="pass-input" value={name} spellCheck={false}
|
||||
placeholder={isFolder ? 'folder name…' : 'file name…'}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); if (ok) onConfirm(trimmed) }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
|
||||
}} />
|
||||
<div className="pass-preview"><span className="pp-lbl">{isRename ? 'renames to' : 'creates'}</span><code>{trimmed ? target + (isFolder ? '/' : '') : '…'}</code></div>
|
||||
<div className="pass-foot"><b>↵</b> {isRename ? 'rename' : 'create'} {noun} · <b>esc</b> cancel</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
src/renderer/src/persist.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Tiny localStorage-backed number persistence for layout (splitter positions
|
||||
* persist across launches). Defensive: never throws if storage is unavailable. */
|
||||
export function loadNum(key: string, fallback: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(key)
|
||||
if (v == null) return fallback
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNum(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJson<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const v = localStorage.getItem(key)
|
||||
return v == null ? fallback : (JSON.parse(v) as T)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
342
src/renderer/src/project.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
/* Renderer-side project store. Loads tree / file index / git state from the
|
||||
* main process over the preload bridge and exposes it in the same shape the UI
|
||||
* already consumed from the mock. When window.helder is absent (e.g. a plain
|
||||
* browser preview) it falls back to the mock so the UI still renders. */
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Change, Diff, FileNode, GitStatus, HelderConfig } from './types'
|
||||
import { DEFAULT_CONFIG, rowId } from './types'
|
||||
import { makeDiff } from './diff'
|
||||
import { rlog } from './log'
|
||||
import { PROJECT as MOCK } from './data'
|
||||
|
||||
export interface RecentProject { path: string; name: string }
|
||||
|
||||
export interface ProjectData {
|
||||
name: string
|
||||
root: string | null
|
||||
branch: string
|
||||
tree: FileNode | null
|
||||
files: Record<string, string>
|
||||
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
|
||||
changes: Change[]
|
||||
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
|
||||
diffs: Record<string, Diff>
|
||||
/** Per row id: that row's own pair. Drives Diff. */
|
||||
rowDiffs: Record<string, Diff>
|
||||
/** Paths that have a staged row. */
|
||||
staged: Set<string>
|
||||
config: HelderConfig
|
||||
isRepo: boolean
|
||||
ready: boolean
|
||||
// Prefetched at startup (parallel to the project load) so the launcher paints
|
||||
// its list with no extra round-trip when there's no project open.
|
||||
recents: RecentProject[]
|
||||
}
|
||||
|
||||
/** Immutable copy of the tree with the children of the node at `path` replaced.
|
||||
* Root is path '' . Returns the tree unchanged when the path isn't found. */
|
||||
function replaceChildren(tree: FileNode, path: string, children: FileNode[]): FileNode {
|
||||
if (tree.path === path) return { ...tree, children }
|
||||
if (!tree.children) return tree
|
||||
return { ...tree, children: tree.children.map((c) => replaceChildren(c, path, children)) }
|
||||
}
|
||||
|
||||
/** Inject the project's theme.css over the built-in dark theme. */
|
||||
function applyTheme(css: string): void {
|
||||
let el = document.getElementById('helder-theme') as HTMLStyleElement | null
|
||||
if (!el) {
|
||||
el = document.createElement('style')
|
||||
el.id = 'helder-theme'
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.textContent = css || ''
|
||||
}
|
||||
|
||||
export interface ProjectActions {
|
||||
openFolder: () => void
|
||||
openProjectPath: (path: string) => void
|
||||
refresh: () => void
|
||||
/** Re-read a single folder's children from disk and splice them into the tree.
|
||||
* Called on every folder expand/collapse so the row reflects on-disk truth
|
||||
* (files added/removed by the agent or an external tool) without a full walk. */
|
||||
refreshDir: (path: string) => void
|
||||
refreshGit: () => void
|
||||
stage: (path: string) => void
|
||||
unstage: (path: string) => void
|
||||
stageAll: () => void
|
||||
unstageAll: () => void
|
||||
commit: (message: string) => Promise<number>
|
||||
push: () => Promise<{ ok: boolean; message: string }>
|
||||
discard: (path: string) => void
|
||||
ensureFile: (path: string) => void
|
||||
/** Force re-read a file from disk into the content index, returning the fresh
|
||||
* text. Unlike ensureFile (which only fills a gap), this always overwrites the
|
||||
* cached copy — so the editable view reflects on-disk truth after the agent or
|
||||
* an external tool rewrites the file. */
|
||||
reloadFile: (path: string) => Promise<string>
|
||||
}
|
||||
|
||||
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
|
||||
|
||||
/** Preview mode has no index, so each mock file is one row. The staged set says
|
||||
* which group it lands in. */
|
||||
function mockChanges(staged: Set<string>): Change[] {
|
||||
return MOCK.changes.map((c) => {
|
||||
const s = staged.has(c.path)
|
||||
return { ...c, staged: s, id: rowId(c.path, s) }
|
||||
})
|
||||
}
|
||||
|
||||
function mockRowDiffs(changes: Change[]): Record<string, Diff> {
|
||||
const out: Record<string, Diff> = {}
|
||||
for (const c of changes) out[c.id] = MOCK.diffs[c.path]
|
||||
return out
|
||||
}
|
||||
|
||||
function mockData(): ProjectData {
|
||||
const staged = new Set(MOCK_STAGED)
|
||||
const changes = mockChanges(staged)
|
||||
return {
|
||||
// non-null root so browser-preview shows the workbench, not the launcher
|
||||
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
|
||||
tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
|
||||
rowDiffs: mockRowDiffs(changes),
|
||||
staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
|
||||
}
|
||||
}
|
||||
|
||||
const emptyData: ProjectData = {
|
||||
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
|
||||
changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
|
||||
}
|
||||
|
||||
const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
|
||||
data: emptyData,
|
||||
actions: {} as ProjectActions,
|
||||
})
|
||||
|
||||
/** Map a git.load() result into the git-derived slice of ProjectData. Shared by
|
||||
* the full reload and the git-only fast path so the two stay in lockstep. */
|
||||
type GitLoadResult = Awaited<ReturnType<NonNullable<typeof window.helder>['git']['load']>>
|
||||
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> {
|
||||
const changes: Change[] = []
|
||||
const rowDiffs: Record<string, Diff> = {}
|
||||
const diffs: Record<string, Diff> = {}
|
||||
const staged = new Set<string>()
|
||||
// File-level pair for Original vs Actual. HEAD is the staged row's left side,
|
||||
// disk is the unstaged row's right side. With a single row both come from it,
|
||||
// because the index then matches whichever end is missing.
|
||||
const whole = new Map<string, { head: string; disk: string; status: GitStatus }>()
|
||||
if (git) {
|
||||
for (const c of git.changes) {
|
||||
const d = makeDiff(c.status, c.original, c.updated)
|
||||
const id = rowId(c.path, c.staged)
|
||||
rowDiffs[id] = d
|
||||
changes.push({ id, path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: c.staged })
|
||||
if (c.staged) staged.add(c.path)
|
||||
const prev = whole.get(c.path)
|
||||
if (!prev) whole.set(c.path, { head: c.original, disk: c.updated, status: c.status })
|
||||
else if (c.staged) whole.set(c.path, { ...prev, head: c.original })
|
||||
// The unstaged row owns the disk copy and the file-level status: a file
|
||||
// staged as modified but deleted on disk reads as deleted.
|
||||
else whole.set(c.path, { head: prev.head, disk: c.updated, status: c.status })
|
||||
}
|
||||
}
|
||||
for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk)
|
||||
return { branch: git ? git.branch : '—', changes, diffs, rowDiffs, staged, isRepo: !!git }
|
||||
}
|
||||
|
||||
export function useProject(): ProjectData {
|
||||
return useContext(Ctx).data
|
||||
}
|
||||
export function useProjectActions(): ProjectActions {
|
||||
return useContext(Ctx).actions
|
||||
}
|
||||
|
||||
export function ProjectProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const bridge = window.helder
|
||||
const [data, setData] = useState<ProjectData>(emptyData)
|
||||
const dataRef = useRef(data)
|
||||
dataRef.current = data
|
||||
const loadSeq = useRef(0)
|
||||
// Git has its OWN counter. It used to share loadSeq, which quietly broke ⌘R:
|
||||
// the git poll (git.refreshInterval, 10s) and the git-column focus refresh
|
||||
// both bump the counter, so any full reload still in flight — the tree walk is
|
||||
// the slow part — saw seq !== loadSeq and bailed out completely. The tree then
|
||||
// never updated while git kept looking fine. Two counters: a git-only read can
|
||||
// no longer cancel a tree read, and each still settles on its newest result.
|
||||
const gitSeq = useRef(0)
|
||||
|
||||
async function loadReal(): Promise<void> {
|
||||
if (!bridge) return
|
||||
// A branch switch fires several watcher pings in quick succession, so
|
||||
// multiple loadReal() calls overlap. Tag each one and only let the newest
|
||||
// apply its result — otherwise a slow/transient mid-checkout read can
|
||||
// resolve last and clobber the correct settled state.
|
||||
const seq = ++loadSeq.current
|
||||
const gseq = ++gitSeq.current
|
||||
const cur = await bridge.project.current()
|
||||
if (seq !== loadSeq.current) return
|
||||
// Bare launch (Spotlight / no project): flip ready immediately so the
|
||||
// launcher paints, skipping the tree/files/git/theme reads it doesn't need.
|
||||
if (!cur.root) {
|
||||
setData((d) => ({ ...emptyData, name: cur.name, root: null, ready: true, recents: d.recents }))
|
||||
return
|
||||
}
|
||||
const [tree, git, config, theme] = await Promise.all([
|
||||
bridge.fs.tree(),
|
||||
bridge.git.load(),
|
||||
bridge.config.get(),
|
||||
bridge.config.theme(),
|
||||
])
|
||||
if (seq !== loadSeq.current) return
|
||||
applyTheme(theme)
|
||||
// The git slice is only applied if no newer git-only read has started since;
|
||||
// otherwise keep the fresher git state and update everything else.
|
||||
const gitFresh = gseq === gitSeq.current
|
||||
setData((d) => ({
|
||||
name: cur.name, root: cur.root,
|
||||
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
|
||||
...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
|
||||
}))
|
||||
// The whole-repo content index is only a fallback (real viewing/search go
|
||||
// through fs.read + ripgrep), and reading every file serially costs seconds.
|
||||
// Build it in the background and patch it in — never block the workbench on
|
||||
// it. Lazily-loaded files (ensureFile) win over the bulk read.
|
||||
bridge.fs.files().then((files) => {
|
||||
if (seq !== loadSeq.current) return
|
||||
setData((d) => ({ ...d, files: { ...(files || {}), ...d.files } }))
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// Fast path for the three git mutations (stage / unstage / commit) + discard:
|
||||
// re-read ONLY git status and patch the git fields, skipping the full
|
||||
// loadReal() that also re-walks the file tree + content index. This is the
|
||||
// direct, immediate refresh those actions trigger — the .git watcher stays a
|
||||
// backstop for git changes made by external tools. Uses gitSeq only, so it
|
||||
// never cancels an in-flight full reload (see the gitSeq note above).
|
||||
async function loadGit(): Promise<void> {
|
||||
if (!bridge) return
|
||||
const seq = ++gitSeq.current
|
||||
const git = await bridge.git.load()
|
||||
if (seq !== gitSeq.current) return
|
||||
setData((d) => ({ ...d, ...deriveGit(git) }))
|
||||
}
|
||||
|
||||
async function loadConfigTheme(): Promise<void> {
|
||||
if (!bridge) return
|
||||
const [config, theme] = await Promise.all([bridge.config.get(), bridge.config.theme()])
|
||||
applyTheme(theme)
|
||||
setData((d) => ({ ...d, config }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) { setData(mockData()); return }
|
||||
// Fetch recents in parallel with the project load (not after it) so the list
|
||||
// is already in state by the time the launcher mounts.
|
||||
bridge.project.recent()
|
||||
.then((list) => setData((d) => ({ ...d, recents: list })))
|
||||
.catch(() => {})
|
||||
loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
|
||||
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
|
||||
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
|
||||
return () => { offProject(); offConfig() }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const actions = useMemo<ProjectActions>(() => {
|
||||
if (!bridge) {
|
||||
// ---- mock-mode actions (preview only) ----
|
||||
const setStaged = (fn: (s: Set<string>) => Set<string>): void =>
|
||||
setData((d) => {
|
||||
const staged = fn(new Set(d.staged))
|
||||
const changes = mockChanges(staged).filter((c) => d.changes.some((o) => o.path === c.path))
|
||||
return { ...d, staged, changes, rowDiffs: mockRowDiffs(changes) }
|
||||
})
|
||||
return {
|
||||
openFolder: () => {},
|
||||
openProjectPath: () => {},
|
||||
refresh: () => setData(mockData()),
|
||||
refreshDir: () => {},
|
||||
refreshGit: () => {},
|
||||
stage: (p) => setStaged((s) => (s.add(p), s)),
|
||||
unstage: (p) => setStaged((s) => (s.delete(p), s)),
|
||||
stageAll: () => setStaged(() => new Set(dataRef.current.changes.map((c) => c.path))),
|
||||
unstageAll: () => setStaged(() => new Set()),
|
||||
commit: async (_msg) => {
|
||||
const cur = dataRef.current
|
||||
const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
|
||||
setData((d) => ({ ...d, changes: d.changes.filter((c) => !c.staged), staged: new Set() }))
|
||||
return n
|
||||
},
|
||||
push: async () => ({ ok: true, message: 'Pushed (preview)' }),
|
||||
discard: (p) => setData((d) => ({
|
||||
...d,
|
||||
changes: d.changes.filter((c) => c.path !== p),
|
||||
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
|
||||
})),
|
||||
ensureFile: () => {},
|
||||
reloadFile: async (p) => dataRef.current.files[p] ?? '',
|
||||
}
|
||||
}
|
||||
// ---- real git-backed actions ----
|
||||
const after = (op: Promise<unknown>): void => { op.then(() => loadGit()).catch(() => {}) }
|
||||
return {
|
||||
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
||||
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
|
||||
refresh: () => { loadReal().catch(() => {}) },
|
||||
refreshDir: (path) => {
|
||||
bridge.fs.readDir(path).then((children) => {
|
||||
setData((d) => (d.tree ? { ...d, tree: replaceChildren(d.tree, path, children) } : d))
|
||||
}).catch(() => {})
|
||||
},
|
||||
refreshGit: () => { loadGit().catch(() => {}) },
|
||||
stage: (p) => after(bridge.git.stage([p])),
|
||||
unstage: (p) => after(bridge.git.unstage([p])),
|
||||
stageAll: () => {
|
||||
// Every path with an unstaged row — including files that already have a
|
||||
// staged row and were edited again since.
|
||||
const unstaged = [...new Set(dataRef.current.changes.filter((c) => !c.staged).map((c) => c.path))]
|
||||
if (unstaged.length) after(bridge.git.stage(unstaged))
|
||||
},
|
||||
unstageAll: () => {
|
||||
const staged = [...dataRef.current.staged]
|
||||
if (staged.length) after(bridge.git.unstage(staged))
|
||||
},
|
||||
commit: async (msg) => {
|
||||
const cur = dataRef.current
|
||||
const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
|
||||
await bridge.git.commit(msg)
|
||||
await loadGit()
|
||||
return n
|
||||
},
|
||||
push: async () => {
|
||||
const r = await bridge.git.push()
|
||||
await loadGit()
|
||||
return r
|
||||
},
|
||||
discard: (p) => after(bridge.git.discard([p])),
|
||||
ensureFile: (path) => {
|
||||
if (dataRef.current.files[path] != null) return
|
||||
bridge.fs.read(path).then((txt) => {
|
||||
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
|
||||
}).catch((e) => rlog.error('fs', 'prime read failed', e, { path }))
|
||||
},
|
||||
reloadFile: async (path) => {
|
||||
try {
|
||||
const txt = await bridge.fs.read(path)
|
||||
setData((d) => ({ ...d, files: { ...d.files, [path]: txt } }))
|
||||
return txt
|
||||
} catch (e) {
|
||||
// Falling back to the cached copy means the editor shows content that
|
||||
// is NOT what's on disk — a save from here can clobber. Worth a line.
|
||||
rlog.error('fs', 'reload failed — serving cached content', e, { path })
|
||||
return dataRef.current.files[path] ?? ''
|
||||
}
|
||||
},
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return <Ctx.Provider value={{ data, actions }}>{children}</Ctx.Provider>
|
||||
}
|
||||
947
src/renderer/src/styles.css
Normal file
@@ -0,0 +1,947 @@
|
||||
/* ============ Helder — Kade: four surfaces, one amber accent ============ */
|
||||
/* Palette, type and geometry come from docs/design (README + Helder IDE.dc.html).
|
||||
Four surfaces only: editor · panel · raised · hover. Amber is reserved for
|
||||
action, state and signal — never a decorative fill. */
|
||||
:root {
|
||||
/* --- surfaces --- */
|
||||
--bg-0:#101720; /* editor canvas, terminal, code blocks */
|
||||
--bg-1:#101720; /* terminals share the editor ground */
|
||||
--bg-2:#18202B; /* panels, title bar, status bar, overlays */
|
||||
--bg-3:#18202B; /* headers / tab strips — same panel surface */
|
||||
--hover:#1E2733; /* row hover fill, nothing else */
|
||||
--active:#232C39;
|
||||
--sel:#232C39; /* selected row */
|
||||
--border:#232C39; /* every hairline */
|
||||
--border-2:#232C39;
|
||||
--raised:#232C39;
|
||||
|
||||
/* --- text on dark --- */
|
||||
--fg-0:#F4F5F4; /* active file, dialog titles, selected row */
|
||||
--fg-1:#E4E7E6; /* body copy, code plain text */
|
||||
--fg-2:#BAC0C0; /* inactive rows, identifiers, secondary copy */
|
||||
--fg-3:#6C7783; /* labels, paths, meta, comments */
|
||||
--fg-4:#3A424C; /* line numbers, disabled */
|
||||
|
||||
/* --- amber, the only accent --- */
|
||||
--accent:#E8913A;
|
||||
--accent-deep:#C4741F;
|
||||
--accent-lite:#F0B476;
|
||||
--accent-soft:rgba(232,145,58,0.10); /* tint fill */
|
||||
--accent-dim:rgba(232,145,58,0.35); /* keycap border on an active toggle */
|
||||
--accent-line:rgba(232,145,58,0.35);
|
||||
--accent-ring:rgba(232,145,58,0.22); /* the 2px focus ring */
|
||||
--row-soft:#1E2733;
|
||||
|
||||
/* --- diff: teal added, amber-deep removed. No green, no red. --- */
|
||||
--add:#8FBFB4;
|
||||
--del:#C4741F;
|
||||
--mod:#F0B476;
|
||||
--ren:#BAC0C0;
|
||||
--add-bg:rgba(143,191,180,0.12);
|
||||
--del-bg:rgba(196,116,31,0.14);
|
||||
|
||||
/* --- syntax: six colours, all muted, weight 400 everywhere --- */
|
||||
--t-key:#C3A6CE; /* keyword · operator · pseudo-class */
|
||||
--t-str:#8FBFB4; /* string · value · type path */
|
||||
--t-num:#F0B476; /* number · tag · hex literal */
|
||||
--t-fn:#E4E7E6; /* plain text · function name */
|
||||
--t-com:#6C7783; /* comment */
|
||||
--t-tag:#F0B476;
|
||||
--t-attr:#BAC0C0;
|
||||
--t-punc:#6C7783;
|
||||
--t-var:#BAC0C0; /* variable · property */
|
||||
--t-const:#F0B476;
|
||||
--t-prop:#BAC0C0;
|
||||
|
||||
/* --- type --- */
|
||||
--ui:"IBM Plex Sans",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
--mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
/* code surfaces (editor + terminals) — overridable from .helder/theme.css */
|
||||
--code-font:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
||||
--code-size:13px;
|
||||
--term-size:12px;
|
||||
|
||||
/* --- geometry --- */
|
||||
--r-sm:2px; /* buttons, inputs, tags, keycaps, code blocks */
|
||||
--r-md:4px; /* panels, cards, menus */
|
||||
--r-lg:8px; /* overlays only */
|
||||
--bar-h:34px; /* the one top-bar height: commit box, Explorer head, view strip */
|
||||
--shadow-menu:0 4px 16px rgba(23,28,34,.10);
|
||||
--shadow-overlay:0 16px 48px rgba(23,28,34,.18);
|
||||
/* Colour transitions only. Nothing lifts, scales or fades. */
|
||||
--ease:cubic-bezier(0.22,1,0.36,1);
|
||||
}
|
||||
|
||||
* { 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:var(--accent-ring); color:var(--fg-0); }
|
||||
|
||||
/* One key chip, used by every shortcut hint in the app — title bar, modal
|
||||
headers, empty editor, context hints. Components may only add layout
|
||||
(flex, min-width, alignment) or a colour that their own surface demands. */
|
||||
kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; color:var(--accent);
|
||||
background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); padding:4px 6px; line-height:1; }
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width:11px; height:11px; }
|
||||
::-webkit-scrollbar-thumb { background:#232C39; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background:#2E3947; background-clip:content-box; }
|
||||
::-webkit-scrollbar-corner { background:transparent; }
|
||||
|
||||
/* ============ shell ============ */
|
||||
.app { display:flex; flex-direction:column; height:100vh; }
|
||||
|
||||
.titlebar {
|
||||
height:44px; flex:0 0 44px; display:flex; align-items:center; position:relative;
|
||||
background:var(--bg-2); border-bottom:1px solid var(--border);
|
||||
padding:0 14px; gap:18px; user-select:none;
|
||||
}
|
||||
/* branch - repository, centered in the bar */
|
||||
.tb-repo {
|
||||
position:absolute; left:50%; transform:translateX(-50%);
|
||||
display:flex; align-items:center; gap:6px; pointer-events:none;
|
||||
font-family:var(--mono); font-size:11.5px; color:var(--fg-2);
|
||||
max-width:46%; white-space:nowrap; overflow:hidden;
|
||||
}
|
||||
.tb-repo svg { color:var(--accent); flex:0 0 auto; }
|
||||
.tb-repo-branch { color:var(--fg-3); }
|
||||
.tb-repo-sep { color:var(--fg-3); }
|
||||
.tb-repo-name { color:var(--fg-0); font-weight:600; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* focus flash — branch - repository, briefly centered; same inline style as the
|
||||
header label, scaled up. It appears and goes on the state alone: no fade, so
|
||||
what is on screen is always the truth about which window has focus. */
|
||||
.focus-flash {
|
||||
position:fixed; inset:0; z-index:200; pointer-events:none;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.ff-card {
|
||||
display:flex; align-items:center; gap:11px;
|
||||
font-family:var(--mono); font-size:19px; line-height:1;
|
||||
padding:15px 22px; border-radius:var(--r-md);
|
||||
background:var(--bg-2); border:1px solid var(--border);
|
||||
border-left:2px solid var(--accent);
|
||||
box-shadow:var(--shadow-overlay);
|
||||
}
|
||||
.ff-card svg { color:var(--accent); flex:0 0 auto; }
|
||||
.ff-card .tb-repo-branch { color:var(--accent); }
|
||||
/* The dash becomes a rule: same divider the rest of the chrome uses. */
|
||||
.ff-card .tb-repo-sep { width:1px; height:17px; background:var(--border-2); font-size:0; margin:0 1px; }
|
||||
.ff-card .tb-repo-name { font-weight:700; letter-spacing:-0.01em; }
|
||||
.traffic { display:flex; gap:8px; flex:0 0 auto; }
|
||||
.traffic i { width:12px; height:12px; border-radius:9999px; display:block; }
|
||||
.traffic .r{background:var(--accent);} .traffic .y{background:var(--fg-3);} .traffic .g{background:var(--fg-4);}
|
||||
|
||||
/* wordmark · project · branch — one baseline-aligned cluster */
|
||||
.tb-title { flex:0 0 auto; display:flex; align-items:baseline; gap:10px; font-family:var(--mono); }
|
||||
.tb-word { font-weight:700; font-size:13px; line-height:1; letter-spacing:.02em; color:var(--fg-0); }
|
||||
.tb-word i { font-style:normal; color:var(--accent); animation:blink 1.1s steps(1,end) infinite; }
|
||||
.tb-proj { font-size:12px; line-height:1; color:var(--fg-3); cursor:pointer; }
|
||||
.tb-proj:hover { color:var(--fg-1); }
|
||||
.tb-sep { font-size:12px; line-height:1; color:var(--fg-3); }
|
||||
.tb-branch { font-size:12px; line-height:1; font-weight:500; color:var(--accent); }
|
||||
|
||||
.tb-crumb { flex:1 1 auto; min-width:0; color:var(--fg-3); font-size:12px; font-family:var(--mono);
|
||||
display:flex; align-items:center; gap:7px; overflow:hidden; white-space:nowrap; }
|
||||
.tb-crumb .seg{color:var(--fg-3);}
|
||||
.tb-crumb .leaf { color:var(--fg-0); font-weight:600; }
|
||||
.tb-crumb .tb-dirty { color:var(--accent); font-size:10px; margin-left:4px; }
|
||||
.tb-spacer { flex:1; }
|
||||
.tb-actions { display:flex; gap:4px; align-items:center; flex:0 0 auto; }
|
||||
/* Toolbar toggle: label + keycap. Off = no fill. On = amber tint fill, the label
|
||||
lifts to text-1 and the keycap border turns amber. The muted case (a toggle
|
||||
that is off by meaning, not by focus) drops label and keycap to text-4. */
|
||||
.tb-btn {
|
||||
font-family:var(--ui); font-size:12px; line-height:1; color:var(--fg-2);
|
||||
background:transparent; border:0; border-radius:var(--r-sm);
|
||||
padding:6px 9px; cursor:pointer; display:flex; align-items:center; gap:8px;
|
||||
transition:color .12s var(--ease), background-color .12s var(--ease);
|
||||
}
|
||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.tb-toggle.on, .tb-toggle.on:hover { background:var(--accent-soft); color:var(--fg-0); font-weight:500; }
|
||||
.tb-toggle.on kbd { border-color:var(--accent-dim); }
|
||||
/* Off by meaning (hidden files are hidden): the whole control steps back. */
|
||||
.tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); }
|
||||
.tb-toggle.muted:hover { color:var(--fg-2); }
|
||||
|
||||
/* 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
|
||||
animate back to it, whichever state the column is in. */
|
||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; --col-bg:var(--bg-2); background:var(--col-bg); }
|
||||
/* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */
|
||||
.col.editor-col { flex:1; min-width:240px; }
|
||||
.col.right-col { --col-bg:var(--bg-2); background:var(--col-bg); }
|
||||
/* Active panel: one step of fill, nothing else. No lift, no shadow, no glow. */
|
||||
.col.panel-active { --col-bg:#1B242F; background:var(--col-bg); }
|
||||
.col.editor-col.panel-active { --col-bg:#131B25; background:var(--col-bg); }
|
||||
.col.right-col.panel-active { --col-bg:#1B242F; background:var(--col-bg); }
|
||||
|
||||
/* ⌘R refresh flash: A, B and C blink light grey and fade back. Two identical
|
||||
animations (a/b) alternate so a second ⌘R replays it — a CSS animation only
|
||||
restarts when the animation-name changes. */
|
||||
.col.refresh-flash-a { animation:col-refresh-a 260ms ease-out; }
|
||||
.col.refresh-flash-b { animation:col-refresh-b 260ms ease-out; }
|
||||
@keyframes col-refresh-a { 0% { background:#2A3542; } 100% { background:var(--col-bg); } }
|
||||
@keyframes col-refresh-b { 0% { background:#2A3542; } 100% { background:var(--col-bg); } }
|
||||
@media (prefers-reduced-motion:reduce) {
|
||||
.col.refresh-flash-a, .col.refresh-flash-b { animation-duration:180ms; animation-timing-function:steps(2); }
|
||||
}
|
||||
|
||||
/* Every divider is one hairline, like the rest of the chrome. The splitter is
|
||||
1px wide in layout; the ::after reaches past it on both sides to give the
|
||||
pointer a 7px target, so the line stays thin without being hard to grab. */
|
||||
.splitter { flex:0 0 1px; cursor:col-resize; background:var(--border); position:relative; z-index:5;
|
||||
transition:background .12s var(--ease); }
|
||||
.splitter::after { content:""; position:absolute; top:0; bottom:0; left:-3px; right:-3px; }
|
||||
.splitter:hover, .splitter.drag { background:var(--accent); }
|
||||
.splitter.h { cursor:row-resize; flex:0 0 1px; width:100%; }
|
||||
.splitter.h::after { left:0; right:0; top:-3px; bottom:-3px; }
|
||||
|
||||
/* panel header */
|
||||
/* Standard panel label: Mono 700 10px, .14em, uppercase, text-4. */
|
||||
.phead {
|
||||
height:var(--bar-h); flex:0 0 var(--bar-h); display:flex; align-items:center; gap:8px;
|
||||
padding:0 12px; font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
|
||||
letter-spacing:.14em; text-transform:uppercase;
|
||||
color:var(--fg-3); border-bottom:1px solid var(--border); user-select:none;
|
||||
}
|
||||
.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.14em; text-transform:uppercase;
|
||||
background:transparent; border:0; padding:0; line-height:1; }
|
||||
.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 ============ */
|
||||
/* One line at rest, so this bar matches the Explorer head and the view strip.
|
||||
Focus grows the box downward inside the git column only. */
|
||||
.commit-box { height:var(--bar-h); flex:0 0 auto; padding:0 12px; border-bottom:1px solid var(--border); display:flex; gap:8px; align-items:center; }
|
||||
.commit-box:focus-within { height:auto; padding:8px 12px; 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:var(--r-sm); color:var(--fg-1); font-family:var(--ui); font-size:12px; line-height:1.5; padding:3px 9px; outline:none; height:26px; }
|
||||
/* Focus is the same everywhere: 2px amber border plus the 22% ring. */
|
||||
.commit-input:focus { border-color:var(--accent); border-width:2px; padding:6px 8px; box-shadow:0 0 0 2px var(--accent-ring); height:72px; }
|
||||
.commit-input::placeholder { color:var(--fg-3); }
|
||||
.push-btn { flex:0 0 26px; display:flex; align-items:center; justify-content:center; width:26px; height:26px; background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-2); cursor:pointer; }
|
||||
.push-btn:hover { background:var(--hover); color:var(--accent); }
|
||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; 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:#f6b35f; }
|
||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||
.git-body { overflow:auto; flex:1; padding:0 0 8px; }
|
||||
/* Group header: the standard 10px mono label, count right in amber. */
|
||||
.git-group { padding:14px 12px 6px; font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
|
||||
letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:8px; }
|
||||
.git-group .gc { margin-left:auto; color:var(--accent); }
|
||||
.git-group .grp-act { display:flex; opacity:0; background:transparent; border:0; color:var(--fg-3); padding:2px; border-radius:var(--r-sm); cursor:pointer; }
|
||||
.git-group:hover .grp-act { opacity:1; }
|
||||
.git-group .grp-act:hover { color:var(--accent); }
|
||||
/* Empty state: one plain sentence starting with an em dash. No icon. */
|
||||
.git-empty { padding:14px 12px; color:var(--fg-3); font-size:12px; line-height:1.6; }
|
||||
.git-empty svg { display:none; }
|
||||
.git-none { padding:2px 12px 8px; font-size:12px; 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:var(--r-sm); padding:0 5px; }
|
||||
.git-divider { height:1px; background:var(--border); margin:16px 12px 0; }
|
||||
.git-row {
|
||||
height:26px; display:flex; align-items:center; gap:8px; padding:0 12px 0 14px; cursor:pointer; position:relative;
|
||||
}
|
||||
/* Where the changed file lives. One header per folder, above its rows, so a
|
||||
name like index.blade.php is never ambiguous. */
|
||||
.git-dir-head { height:24px; display:flex; align-items:center; gap:8px; padding:0 12px 0 14px;
|
||||
font-family:var(--mono); font-size:11px; line-height:1; color:var(--fg-3);
|
||||
white-space:nowrap; overflow:hidden; }
|
||||
.git-dir-head .folder-ic { width:14px; height:14px; flex:0 0 14px; }
|
||||
.git-dir-head .gdh-path { overflow:hidden; text-overflow:ellipsis; direction:rtl; text-align:left; }
|
||||
/* ===== Row states — one language for every list that offers a row to click ====
|
||||
rest — no fill.
|
||||
hover — one step of fill (--hover) and nothing else. No rule, no lift.
|
||||
selected — --sel fill plus a 2px amber rule on the left; the label lifts to
|
||||
text-1. The keyboard cursor and the open file are both "selected",
|
||||
so they read the same: only one row in a list ever carries it.
|
||||
Lists covered: Git, Explorer, Recent files, the search overlay (file names,
|
||||
file headers, content lines), the project palette, the ⌘-click popup.
|
||||
A context menu is deliberately left out — it is a menu, not a list of state. */
|
||||
.git-row, .tree-row, .hist-row, .fres, .sr-file, .sr-line, .pres, .sym-row { position:relative; }
|
||||
.git-row:hover, .tree-row:hover, .hist-row:hover, .fres:hover,
|
||||
.sr-file:hover, .sr-line:hover, .pres:hover, .sym-row:hover { background:var(--hover); }
|
||||
.git-row.ctx, .git-row.kbd,
|
||||
.tree-row.ctx, .tree-row.kbd,
|
||||
.hist-row.sel, .fres.sel, .sr-line.sel, .pres.sel, .sym-row.hi,
|
||||
.git-row.active, .tree-row.active { background:var(--sel); }
|
||||
.git-row.ctx::before, .git-row.kbd::before,
|
||||
.tree-row.ctx::before, .tree-row.kbd::before,
|
||||
.hist-row.sel::before, .fres.sel::before, .sr-line.sel::before, .pres.sel::before, .sym-row.hi::before,
|
||||
.git-row.active::before, .tree-row.active::before {
|
||||
content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent);
|
||||
}
|
||||
.git-row.ctx .git-act, .git-row.kbd .git-act { visibility:visible; }
|
||||
/* Status letter: staged rows read teal, unstaged amber-soft. */
|
||||
.git-stat { width:12px; flex:0 0 12px; font-family:var(--mono); font-size:11px; font-weight:600; line-height:1; color:var(--mod); }
|
||||
.git-row.staged .git-stat { color:var(--add); }
|
||||
.git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);}
|
||||
.git-name { font-family:var(--ui); font-size:12px; color:var(--fg-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* Files in the root `tests/` or `cypress/` folder stay a step greyer, so real source changes read first. Git list only. */
|
||||
.git-row.test .git-name { color:var(--fg-3); }
|
||||
.git-row.active .git-name, .git-row.kbd .git-name { color:var(--fg-0); font-weight:500; }
|
||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||
.git-dir { color:var(--fg-3); font-size:11px; font-family:var(--mono); 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:flex; visibility:hidden; align-items:center; justify-content:center; width:18px; height:18px; padding:0; background:transparent; border:0; border-radius:var(--r-sm); color:var(--fg-3); cursor:pointer; margin-left:4px; }
|
||||
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
|
||||
.git-row:hover .git-act { visibility:visible; }
|
||||
.git-act:hover { color:var(--accent); }
|
||||
.git-foot { border-top:1px solid var(--border); padding:12px; display:flex; align-items:center; gap:8px;
|
||||
font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--fg-3); }
|
||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-3); }
|
||||
.branch-chip svg { display:none; }
|
||||
.branch-chip b { font-weight:400; color:var(--fg-3); }
|
||||
|
||||
/* ============ file tree ============ */
|
||||
.tree-body { overflow:auto; flex:1; padding:6px 0 14px; }
|
||||
.tree-row { display:flex; align-items:center; gap:7px; padding:0 12px 0 0; cursor:pointer; white-space:nowrap; position:relative; height:24px; }
|
||||
/* A folder never reaches `active` — it opens in the tree, not in the viewer —
|
||||
but it takes focus and hover like a file. See the row-state block above. */
|
||||
.tw { display:inline-flex; align-items:center; justify-content:center; width:10px; flex:0 0 10px; color:var(--fg-3); font-size:11px; }
|
||||
/* An expanded folder is the one place a chevron turns amber: it marks the path
|
||||
down to the open file. */
|
||||
.tree-row.folder.open .tw { color:var(--accent); }
|
||||
.tree-label { font-family:var(--ui); font-size:13px; color:var(--fg-2); overflow:hidden; text-overflow:ellipsis; }
|
||||
.tree-row.active .tree-label, .tree-row.kbd .tree-label { color:var(--fg-0); font-weight:500; }
|
||||
.tree-row.folder.open .tree-label { color:var(--fg-0); }
|
||||
.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; color:var(--add); }
|
||||
.tree-badge.M{color:var(--add);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
|
||||
|
||||
/* File-type badge: ink text on a per-extension fill from the syntax palette.
|
||||
Sized to be read at a glance, not to disappear next to the file name. */
|
||||
/* One fixed width for every badge (the longest label is four characters), so the
|
||||
file names line up in a column instead of stepping in and out. */
|
||||
.ficon { flex:0 0 28px; width:28px; border-radius:var(--r-sm); padding:3px 0; display:inline-flex; align-items:center; justify-content:center;
|
||||
font-family:var(--mono); font-size:10px; font-weight:600; line-height:1; overflow:hidden; }
|
||||
.ficon span { color:var(--bg-0); font-size:10px; line-height:1; letter-spacing:0; text-align:center; }
|
||||
.folder-ic { width:16px; height:16px; flex:0 0 16px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-3); }
|
||||
|
||||
/* ============ editor ============ */
|
||||
|
||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
||||
/* One grid column, minmax(max-content, 1fr). The track's base is the longest
|
||||
line, so every row stretches to it and keeps painting its add/del background
|
||||
all the way to the right edge. Plain block rows stop at the viewport, so a
|
||||
changed line lost its colour the moment you scrolled right.
|
||||
The 1fr max handles the other direction: when the file is narrower than the
|
||||
pane the track grows to fill it. Do not flip this to minmax(100%, max-content)
|
||||
— a track only grows past its base into free space, and a scrolled pane has
|
||||
none, so it would pin every row to the viewport width again.
|
||||
align-content:start stops a short file from stretching rows vertically. */
|
||||
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); font-weight:400; line-height:20px; padding:10px 0 40px;
|
||||
display:grid; grid-template-columns:minmax(max-content, 1fr); align-content:start; background:var(--bg-0); }
|
||||
.ln-row { display:flex; align-items:flex-start; min-height:20px; }
|
||||
.ln-row.cursor { background:rgba(255,255,255,0.03); }
|
||||
/* A changed row: the fill plus a 2px rule on the left. The diff reads on shape
|
||||
and on the rule, never on alarm colour. */
|
||||
.ln-row.add { background:var(--add-bg); box-shadow:inset 2px 0 0 var(--add); }
|
||||
.ln-row.del { background:var(--del-bg); box-shadow:inset 2px 0 0 var(--del); }
|
||||
.ln-row.selrange { background:var(--accent-soft); }
|
||||
.ln-gutter { flex:0 0 56px; width:56px; text-align:right; padding-right:14px; color:var(--fg-4); user-select:none; cursor:pointer; font-size:13px; }
|
||||
.ln-row.cursor .ln-gutter { color:var(--fg-2); }
|
||||
/* A changed line lifts its own number — never leave it at the disabled grey. */
|
||||
.ln-row.add .ln-gutter { color:var(--add); }
|
||||
.ln-row.del .ln-gutter { color:var(--del); }
|
||||
.ln-gutter:hover { color:var(--fg-2); }
|
||||
.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); }
|
||||
/* flex-basis auto (not 0) so the line's real width counts towards the row's
|
||||
intrinsic size. With basis 0 the grid track above collapses to the viewport
|
||||
and the add/del background stops at the fold again. */
|
||||
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 0; min-width:0; }
|
||||
.editor.diff .ln-code { padding-left:6px; }
|
||||
/* 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. Block flow, not grid:
|
||||
an auto grid row takes its height from the row's *unwrapped* line box, so a
|
||||
folded line kept a 20px row and painted over the lines under it. Nothing is
|
||||
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; }
|
||||
.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 .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 div { display:flex; gap:14px; align-items:center; justify-content:space-between; min-width:260px; padding:6px 0; color:var(--fg-1); }
|
||||
|
||||
/* 34px strip: state label, the two counts, then the segmented view control. */
|
||||
.diff-bar { height:var(--bar-h); flex:0 0 var(--bar-h); display:flex; align-items:center; gap:14px; padding:0 14px; background:var(--bg-0); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-3); }
|
||||
.diff-bar .git-stat { width:auto; flex:0 0 auto; font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
|
||||
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 .d{color:var(--del);font-family:var(--mono);font-weight:500;font-size:12px;}
|
||||
/* segmented 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 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;
|
||||
transition:color .12s var(--ease), background-color .12s var(--ease); }
|
||||
.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.on, .diff-bar .seg button.on:hover { background:var(--accent); color:#171C22; font-weight:500; }
|
||||
.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
|
||||
again, so the two git rows can be told apart. */
|
||||
.db-side {
|
||||
font-family:var(--mono); font-size:11px; color:var(--fg-3); letter-spacing:0;
|
||||
padding:0; border:0; white-space:nowrap;
|
||||
}
|
||||
|
||||
/* image preview (viewer shows a picture, not text) */
|
||||
.img-view { flex:1; min-height:0; overflow:auto; display:flex; align-items:center; justify-content:center; padding:24px; background:var(--bg-0); }
|
||||
.img-view-img {
|
||||
max-width:100%; max-height:100%; object-fit:contain; border-radius:var(--r-sm);
|
||||
/* Checkerboard so transparent PNGs/SVGs read clearly on the dark surface. */
|
||||
background-color:var(--bg-2);
|
||||
background-image:
|
||||
linear-gradient(45deg, #232C39 25%, transparent 25%), linear-gradient(-45deg, #232C39 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #232C39 75%), linear-gradient(-45deg, transparent 75%, #232C39 75%);
|
||||
background-size:20px 20px;
|
||||
background-position:0 0, 0 10px, 10px -10px, -10px 0;
|
||||
border:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* rendered-markdown preview (Preview view option) */
|
||||
.md-view { flex:1; overflow:auto; padding:0 0 48px; background:var(--bg-0); }
|
||||
/* The reading surface: Sans body at 68ch, mono headings, amber list markers. */
|
||||
.md-body { max-width:68ch; margin:0; padding:18px 20px 48px; color:var(--fg-1); font-family:var(--ui); font-size:14px; line-height:1.7; text-wrap:pretty; }
|
||||
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 { color:var(--fg-0); font-family:var(--mono); font-weight:700; line-height:1.2; margin:1.6em 0 .6em; }
|
||||
.md-body h1 { font-size:24px; line-height:1.1; letter-spacing:-.03em; }
|
||||
.md-body h2 { font-size:24px; line-height:1.1; letter-spacing:-.03em; }
|
||||
.md-body h3 { font-size:17px; padding-bottom:8px; border-bottom:1px solid var(--border); }
|
||||
.md-body h4 { font-size:15px; }
|
||||
.md-body h1:first-child, .md-body h2:first-child, .md-body h3:first-child { margin-top:.2em; }
|
||||
.md-body p { margin:1em 0; }
|
||||
.md-body a { color:var(--accent); text-decoration:none; border-bottom:1px solid var(--border); }
|
||||
.md-body a:hover { color:var(--fg-0); border-bottom-color:var(--accent); }
|
||||
/* An em dash marks a list item. Never a bullet, never an emoji. */
|
||||
.md-body ul { margin:1em 0; padding-left:0; list-style:none; }
|
||||
.md-body ol { margin:1em 0; padding-left:1.6em; }
|
||||
.md-body ul > li { position:relative; padding-left:22px; margin:.5em 0; }
|
||||
.md-body ul > li::before { content:"—"; position:absolute; left:0; color:var(--accent); }
|
||||
.md-body ol > li { margin:.5em 0; }
|
||||
.md-body ol > li::marker { color:var(--accent); }
|
||||
.md-body blockquote { margin:1.2em 0; padding:0 0 0 16px; border-left:2px solid var(--accent);
|
||||
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 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);
|
||||
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 code { font-size:13px; line-height:20px; color:var(--fg-1); background:none; border:0; padding:0; white-space:pre; }
|
||||
.md-body strong { color:var(--fg-0); font-weight:600; }
|
||||
/* tables scroll on their own so a wide one never widens the whole preview */
|
||||
.md-body table.md-table { display:block; width:max-content; max-width:100%; overflow-x:auto; border-collapse:collapse;
|
||||
margin:1.2em 0; font-size:13px; border:1px solid var(--border); border-radius:var(--r-sm); }
|
||||
.md-body table.md-table th, .md-body table.md-table td { border-bottom:1px solid var(--border); padding:10px 14px; text-align:left; vertical-align:top; color:var(--fg-2); }
|
||||
.md-body table.md-table th { background:var(--bg-2); color:var(--fg-3); font-family:var(--mono); font-weight:700;
|
||||
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; }
|
||||
|
||||
/* gutter change bars (Original / Diff) */
|
||||
.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:transparent; }
|
||||
|
||||
/* 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; }
|
||||
/* 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
|
||||
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;
|
||||
background:var(--bg-2); border-bottom:1px solid var(--border); -webkit-app-region:drag; }
|
||||
.app.fullscreen .split-head { padding-left:16px; }
|
||||
.split-head button { -webkit-app-region:no-drag; }
|
||||
.split-head .sh-name { font-family:var(--mono); font-weight:500; font-size:12px; color:var(--fg-0); }
|
||||
.split-head .git-stat { width:auto; flex:0 0 auto; font-family:var(--mono); font-weight:700; font-size:10px;
|
||||
letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
|
||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:8px; background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); color:var(--fg-2); font-family:var(--ui); font-size:12px; padding:6px 9px; cursor:pointer; }
|
||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.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); }
|
||||
.split-label { height:30px; flex:0 0 30px; display:flex; align-items:center; gap:10px; padding:0 16px;
|
||||
font-family:var(--mono); font-weight:700; font-size:10px; letter-spacing:.14em; text-transform:uppercase;
|
||||
color:var(--fg-3); background:var(--bg-0); border-bottom:1px solid var(--border); }
|
||||
.split-pane.right .split-label { color:var(--accent); }
|
||||
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-4); font-family:var(--mono); font-weight:400; font-size:11px; }
|
||||
/* Both panes number identically so the eye can travel across. Line box 22px. */
|
||||
.split-body .editor { line-height:22px; }
|
||||
.split-body .ln-row { min-height:22px; }
|
||||
.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)
|
||||
* 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.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex,.ce-pre .token.string,.ce-pre .token.attr-value,.ce-pre .token.char,.ce-pre .token.regex{color:var(--t-str);}
|
||||
.ln-code .token.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);}
|
||||
.ln-code .token.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);}
|
||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata,.ce-pre .token.comment,.ce-pre .token.prolog,.ce-pre .token.doctype,.ce-pre .token.cdata{color:var(--t-com);}
|
||||
.ln-code .token.tag,.ce-pre .token.tag{color:var(--t-tag);}
|
||||
.ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);}
|
||||
.ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);}
|
||||
.ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);}
|
||||
.ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);}
|
||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin,.ce-pre .token.constant,.ce-pre .token.boolean,.ce-pre .token.builtin{color:var(--t-const);}
|
||||
.ln-code .token.property,.ln-code .token.property-access,.ce-pre .token.property,.ce-pre .token.property-access{color:var(--t-prop);}
|
||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name,.ce-pre .token.class-name,.ce-pre .token.maybe-class-name{color:var(--t-attr);}
|
||||
.ln-code .token.parameter,.ce-pre .token.parameter{color:var(--fg-0);}
|
||||
.ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);}
|
||||
.ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);}
|
||||
.ln-code .token.entity,.ln-code .token.url,.ce-pre .token.entity,.ce-pre .token.url{color:var(--t-prop);}
|
||||
.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) ============ */
|
||||
/* 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
|
||||
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);
|
||||
font-family:var(--mono); font-weight:700; font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); user-select:none; }
|
||||
.term-head .dot { width:6px; height:6px; border-radius:9999px; background:var(--fg-4); }
|
||||
.term-head .dot.live { background:var(--accent); }
|
||||
.term-head .lbl { color:var(--fg-3); font-family:var(--mono); }
|
||||
.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-4); font-family:var(--mono); letter-spacing:.14em; }
|
||||
.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 steps(1,end) infinite; vertical-align:-2px; }
|
||||
/* The one animation in the whole editor: stepped on/off, never a fade. */
|
||||
@keyframes blink { 0%,49%{opacity:1;} 50%,100%{opacity:0;} }
|
||||
|
||||
/* ============ overlays ============ */
|
||||
/* One overlay at a time, on a flat ink scrim. No blur, no entrance animation. */
|
||||
.scrim { position:fixed; inset:0; background:rgba(16,23,32,.72); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding:34px; }
|
||||
.palette { width:620px; max-width:92vw; background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--shadow-overlay); overflow:hidden; }
|
||||
.palette .pi, .search-modal .pi { height:54px; flex:0 0 54px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--border); }
|
||||
.palette .pi svg, .search-modal .pi svg { color:var(--accent); }
|
||||
.palette .pi input, .search-modal .pi input { flex:1; min-width:0; background:transparent; border:0; outline:0; color:var(--fg-0);
|
||||
font-family:var(--mono); font-weight:500; font-size:16px; caret-color:var(--accent); }
|
||||
.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); }
|
||||
.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; }
|
||||
.palette .pi .mode-chip, .search-modal .pi .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:11px; color:var(--fg-3);
|
||||
border:0; border-left:1px solid var(--border); border-radius:0; padding:0 0 0 12px; font-family:var(--mono); }
|
||||
/* File-type filter chips. All chips read as "on" while no type is chosen, so the
|
||||
* default state says "every type" without a separate All control. */
|
||||
.search-modal .pi .ext-filters { flex:0 1 auto; min-width:0; display:flex; align-items:center; gap:4px; overflow:hidden; }
|
||||
.search-modal .ext-chip { flex:0 0 auto; font-family:var(--mono); font-weight:600; font-size:10px; line-height:1; padding:4px 6px;
|
||||
border:1px solid var(--border-2); border-radius:var(--r-sm); background:transparent; color:var(--fg-2); cursor:pointer; }
|
||||
.search-modal .ext-chip:hover { color:var(--fg-0); }
|
||||
.search-modal .ext-chip.on { color:#171C22; border-color:var(--accent); background:var(--accent); }
|
||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||
.pres { height:38px; display:flex; align-items:center; gap:10px; padding:0 14px; cursor:pointer; }
|
||||
.pres .pn { font-family:var(--ui); font-size:13px; font-weight:500; color:var(--fg-2); }
|
||||
.pres.sel .pn { 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; }
|
||||
/* Empty and error states: one plain sentence. No illustration, no apology. */
|
||||
.pempty { padding:12px 14px; text-align:left; color:var(--fg-3); font-family:var(--ui); font-size:12px; line-height:1.6; }
|
||||
|
||||
/* combined search modal (content + files) */
|
||||
.search-modal { width:100%; height:100%; max-width:none; 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; }
|
||||
.search-cols { display:flex; min-height:0; flex:1; }
|
||||
/* The column that holds the selection takes 70%; the rest divide what is left,
|
||||
* so two columns give 70/30 and three give 70/15/15. */
|
||||
/* Resting widths: this file 230 · project flex · files 250, split by hairlines.
|
||||
The focused column then takes 70% and the other two divide what is left — the
|
||||
side columns stay shrinkable (flex-shrink 1) so they give the room back. */
|
||||
.search-cols > div { min-width:0; overflow:auto; padding:12px 0; transition:flex .14s var(--ease); }
|
||||
/* Higher specificity than `.search-cols > div` above, or its min-width:0 wins
|
||||
and a side column collapses when the other one takes its 70%. */
|
||||
.search-cols > .sc-infile { flex:0 1 230px; min-width:180px; border-right:1px solid var(--border); }
|
||||
.search-cols > .sc-left { flex:1 1 auto; }
|
||||
.search-cols > .sc-right { flex:0 1 250px; min-width:180px; border-left:1px solid var(--border); }
|
||||
.search-cols > div.active { flex:0 0 70%; }
|
||||
.search-cols > .sc-infile.active, .search-cols > .sc-right.active { flex:0 0 70%; }
|
||||
.sc-head { position:sticky; top:0; z-index:1; background:var(--bg-2); padding:0 16px 10px;
|
||||
font-family:var(--mono); font-weight:700; font-size:10px; line-height:1; letter-spacing:.14em; text-transform:uppercase;
|
||||
color:var(--fg-3); display:flex; align-items:center; gap:10px; }
|
||||
.sc-infile .sc-head, .sc-right .sc-head { padding:0 14px 10px; }
|
||||
.sc-infile .sc-head .scf-name { flex:1 1 auto; min-width:0; font-size:10px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sc-infile .sc-head svg { display:none; }
|
||||
/* The focused column's count becomes an amber chip with ink text. */
|
||||
.sc-head .sc-ct { color:var(--fg-3); background:transparent; border:0; border-radius:var(--r-sm); padding:0; line-height:1; font-size:10px; font-weight:600; letter-spacing:0; }
|
||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pempty.sm { padding:0 14px; text-align:left; font-size:12px; }
|
||||
.fres { min-height:26px; display:flex; align-items:center; gap:8px; padding:4px 14px; cursor:pointer; }
|
||||
.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.35; }
|
||||
.fres-txt .fn { font-family:var(--ui); font-size:12px; color:var(--fg-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.fres.sel .fres-txt .fn { color:var(--fg-0); font-weight:500; }
|
||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||
.fres-txt .fd { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* Shared overlay footer: key hints left, the esc chip right. */
|
||||
.ov-foot { height:38px; flex:0 0 38px; display:flex; align-items:center; gap:14px; padding:0 18px;
|
||||
border-top:1px solid var(--border); font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||
.ov-foot b { color:var(--accent); font-weight:400; }
|
||||
.ov-foot-spacer { flex:1; }
|
||||
.esc-chip { font-family:var(--mono); font-size:11px; color:var(--fg-3);
|
||||
border:1px solid var(--border-2); border-radius:var(--r-sm); padding:4px 6px; }
|
||||
|
||||
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
|
||||
.history-modal { width:560px; max-width:92vw; 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; }
|
||||
.history-modal .pi { height:50px; flex:0 0 50px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--border); }
|
||||
.history-modal .pi svg { flex:0 0 auto; color:var(--accent); }
|
||||
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-0); font-family:var(--mono); font-weight:700; font-size:14px; line-height:1; }
|
||||
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:12px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:var(--r-sm); padding:4px 6px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
||||
/* stable gutter: without it the scrollbar eats into the rows, so the change dot
|
||||
sits at a different distance from the edge than the header above it. */
|
||||
.hist-list { max-height:460px; overflow-y:auto; scrollbar-gutter:stable; padding:6px 0; }
|
||||
.hist-row { height:38px; display:flex; align-items:center; gap:10px; padding:0 14px; cursor:pointer; position:relative; }
|
||||
/* ⌘↑/⌘↓ moves the selection while the pointer stays put, so hover has to give
|
||||
way until the mouse moves again — otherwise two rows read as focused. */
|
||||
.hist-list.kbd-nav .hist-row:hover:not(.sel) { background:transparent; }
|
||||
.hist-list.kbd-nav .hist-row:hover:not(.sel)::before { content:none; }
|
||||
.search-cols.kbd-nav .sr-line:hover:not(.sel), .search-cols.kbd-nav .fres:hover:not(.sel) { background:transparent; }
|
||||
.search-cols.kbd-nav .sr-line:hover:not(.sel)::before, .search-cols.kbd-nav .fres:hover:not(.sel)::before { content:none; }
|
||||
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||
.hist-txt .fn { font-family:var(--ui); font-size:13px; font-weight:500; color:var(--fg-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.hist-row.sel .hist-txt .fn { color:var(--fg-0); }
|
||||
.hist-txt .fd { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* content search */
|
||||
.search-results { flex:1; overflow:auto; padding:0 0 8px; }
|
||||
/* Result group header: type badge, path, hit count right. */
|
||||
.sr-file { padding:14px 16px 6px; font-family:var(--mono); font-size:12px; line-height:1.6; 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:12px; }
|
||||
/* Hit line: 64px number column, 24px line box. */
|
||||
.sr-line { display:grid; grid-template-columns:64px 1fr; align-items:center; padding:0; font-family:var(--mono); font-size:12px; line-height:24px; cursor:pointer; color:var(--fg-2); }
|
||||
.sr-line .no { color:var(--fg-4); text-align:right; padding-right:14px; }
|
||||
.sr-line.sel .no { color:var(--accent); }
|
||||
.sr-line.sel { color:var(--fg-1); }
|
||||
.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; padding-right:14px; }
|
||||
.sr-line mark { background:transparent; color:var(--accent-lite); border-radius:var(--r-sm); }
|
||||
.sr-line.sel mark { background:var(--accent); color:#171C22; }
|
||||
|
||||
/* pass-on-to-agent inline popup */
|
||||
.pass-pop { position:fixed; z-index:85; width:460px; max-width:92vw; background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-md); box-shadow:var(--shadow-menu); padding:14px; display:flex; flex-direction:column; gap:12px; }
|
||||
.pass-head { display:flex; align-items:center; gap:10px; font-family:var(--mono); font-weight:700; font-size:13px; color:var(--fg-0); }
|
||||
.pass-head svg { color:var(--accent); }
|
||||
.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:12px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:var(--r-sm); padding:4px 6px; }
|
||||
.pass-input { width:100%; box-sizing:border-box; height:38px; background:var(--bg-0); border:2px solid var(--accent); border-radius:var(--r-sm);
|
||||
box-shadow:0 0 0 2px var(--accent-ring); color:var(--fg-1); font-family:var(--mono); font-size:13px; padding:0 10px; outline:none; caret-color:var(--accent); }
|
||||
.pass-input::placeholder { color:var(--fg-3); }
|
||||
.pass-preview { margin:0; display:flex; align-items:center; gap:10px; min-width:0; }
|
||||
.pass-preview .pp-lbl { font-family:var(--mono); font-weight:700; font-size:10px; text-transform:uppercase; letter-spacing:.14em; color:var(--fg-3); flex:0 0 auto; }
|
||||
.pass-preview code { font-family:var(--mono); font-size:12px; color:#171C22; background:var(--accent); border-radius:var(--r-sm); padding:4px 8px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
|
||||
.pass-foot { margin:0; font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||
.pass-foot b { color:var(--accent); font-weight:400; }
|
||||
|
||||
/* 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 */
|
||||
/* The floating context menu. Its own class (not bare `.ctx`) so this
|
||||
position:fixed rule can never collide with the `.tree-row.ctx` / `.git-row.ctx`
|
||||
highlight class — that collision pulled the highlighted row out of flow and
|
||||
made the row beneath it appear to vanish while the menu was open. */
|
||||
/* ⌘-click on a PHP class: declarations on top, references under them. Sized to
|
||||
read a line of code, capped so it never covers the whole viewer. */
|
||||
.sym-popup { width:min(700px, 82vw); max-height:min(72vh, 640px); display:flex; flex-direction:column;
|
||||
background:var(--bg-2); border:1px solid var(--border); border-left:2px solid var(--accent); border-radius:var(--r-md); box-shadow:var(--shadow-overlay); overflow:hidden; }
|
||||
.sym-head { height:50px; flex:0 0 50px; display:flex; align-items:center; gap:10px; padding:0 18px; border-bottom:1px solid var(--border); background:var(--bg-2); }
|
||||
.sym-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); font-weight:700; }
|
||||
.sym-count { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||
.sym-body { overflow:auto; padding-bottom:6px; }
|
||||
.sym-sec { padding:14px 16px 6px; font-family:var(--mono); font-weight:700; font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
|
||||
.sym-row { cursor:pointer; }
|
||||
.sym-row.def { display:flex; align-items:center; gap:8px; padding:4px 13px; font-size:12px; color:var(--fg-1); }
|
||||
.sym-row.def .sym-path { font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sym-row.def .sym-kind { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:3px; padding:0 5px; }
|
||||
.sym-row.def .no { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||
.sym-popup .sr-line { padding-left:26px; }
|
||||
/* What the declaration is built from: extends / implements / uses. A name this
|
||||
project declares is a button that reloads the popup on it. */
|
||||
.sym-rel { padding:2px 13px 6px 34px; display:flex; flex-direction:column; gap:3px; }
|
||||
.sym-rel-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||||
.sym-rel-key { font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); min-width:66px; }
|
||||
.sym-chip { font-family:var(--mono); font-size:11px; line-height:17px; padding:0 7px; border:1px solid var(--border); border-radius:3px; background:transparent; color:var(--fg-3); }
|
||||
.sym-chip.known { color:var(--fg-1); border-color:var(--border-2); cursor:pointer; }
|
||||
.sym-chip.known:hover { color:var(--accent); border-color:var(--accent-dim); background:var(--accent-soft); }
|
||||
|
||||
/* A class name this project declares, underlined only while ⌘ is held — the
|
||||
affordance shows exactly when the click would do something.
|
||||
The highlight layer is pointer-events:none so the textarea below it keeps the
|
||||
caret; while ⌘ is held these tokens alone take the mouse back, which is what
|
||||
gives them a real :hover, a pointer cursor and a click target carrying the
|
||||
name. Everything else on the line still falls through to the textarea. */
|
||||
.code-edit.sym-live .token.sym, .editor.sym-live .token.sym {
|
||||
pointer-events:auto; cursor:pointer;
|
||||
text-decoration:underline; text-decoration-color:var(--accent); text-underline-offset:3px;
|
||||
}
|
||||
.code-edit.sym-live .token.sym:hover, .editor.sym-live .token.sym:hover { filter:brightness(1.25); }
|
||||
|
||||
.ctx-menu { position:fixed; z-index:80; background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-md); padding:8px 0; width:320px; box-shadow:var(--shadow-menu); }
|
||||
.ctx-item { height:30px; display:flex; align-items:center; gap:10px; padding:0 14px; cursor:pointer;
|
||||
font-family:var(--ui); font-size:13px; color:var(--fg-2); position:relative; border-left:2px solid transparent; }
|
||||
.ctx-item:hover, .ctx-item.hi { background:var(--sel); color:var(--fg-0); font-weight:500; border-left-color:var(--accent); }
|
||||
.ctx-item:hover .ic, .ctx-item.hi .ic { color:var(--accent); }
|
||||
.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); }
|
||||
.ctx-item.danger, .ctx-item.danger .ic { color:var(--del); }
|
||||
.ctx-item.danger:hover, .ctx-item.danger.hi { color:var(--del); }
|
||||
.ctx-item.danger:hover .ic, .ctx-item.danger.hi .ic { color:var(--del); }
|
||||
.ctx-item.primary .ic { color:var(--fg-3); }
|
||||
.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
|
||||
.ctx-sep { height:1px; background:var(--border); margin:6px 0; }
|
||||
.ctx-note { padding:0 14px 8px; margin-bottom:8px; border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-3); font-family:var(--mono); line-height:1.5; overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* toast */
|
||||
.toast-wrap { position:fixed; bottom:44px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
|
||||
.toast { background:var(--bg-2); border:1px solid var(--border); border-left:2px solid var(--accent); border-radius:var(--r-md); padding:10px 14px; box-shadow:var(--shadow-menu); display:flex; align-items:center; gap:12px; }
|
||||
@keyframes tin { from{opacity:0;} }
|
||||
.toast .tt { font-family:var(--ui); font-size:13px; color:var(--fg-0); }
|
||||
.toast .tref { font-family:var(--mono); font-size:12px; color:#171C22; background:var(--accent); border-radius:var(--r-sm); padding:3px 7px; }
|
||||
|
||||
/* ============ status bar ============ */
|
||||
/* 28px, display only: nothing here is clickable and nothing navigates. */
|
||||
.statusbar {
|
||||
height:28px; flex:0 0 28px; display:flex; align-items:center; gap:16px; padding:0 14px;
|
||||
background:var(--bg-2); border-top:1px solid var(--border);
|
||||
font-family:var(--mono); font-size:11px; line-height:1; color:var(--fg-3); user-select:none;
|
||||
}
|
||||
.statusbar .sb-branch { color:var(--accent); }
|
||||
.statusbar .sb-add { color:var(--add); }
|
||||
.statusbar .sb-del { color:var(--del); }
|
||||
.statusbar .sb-spacer { flex:1; }
|
||||
.statusbar .sb-path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:52%; }
|
||||
.statusbar .sb-lang { color:var(--fg-2); }
|
||||
|
||||
/* Tooltip: one surface, one border, no arrow and no shadow. The current state
|
||||
is named inside the sentence in amber. */
|
||||
.tip-host { display:inline-flex; }
|
||||
.tip {
|
||||
position:fixed; z-index:120; max-width:300px; pointer-events:none;
|
||||
background:var(--raised); border:1px solid var(--fg-4); border-radius:var(--r-sm);
|
||||
padding:8px 10px; font-family:var(--ui); font-size:12px; line-height:1.5; color:var(--fg-1);
|
||||
}
|
||||
.tip b { color:var(--accent); font-weight:400; }
|
||||
|
||||
|
||||
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
|
||||
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; background:var(--bg-0); }
|
||||
.ce-gutterwrap { flex:0 0 56px; overflow:hidden; position:relative; background:var(--bg-0); }
|
||||
.ce-gutter {
|
||||
padding:10px 14px 0 0; will-change:transform;
|
||||
white-space:pre; text-align:right; line-height:20px;
|
||||
color:var(--fg-4); font-family:var(--code-font); font-size:13px; user-select:none;
|
||||
}
|
||||
.ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; }
|
||||
/* min-height keeps the inset:0 textarea filling the pane on short/empty files,
|
||||
so a click anywhere in the blank area below the last line still lands. */
|
||||
.ce-inner { position:relative; width:max-content; min-width:100%; min-height:100%; }
|
||||
.ce-pre, .ce-ta {
|
||||
margin:0; padding:10px 16px 40px 0; border:0;
|
||||
font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
|
||||
white-space:pre; tab-size:4; -moz-tab-size:4; letter-spacing:0;
|
||||
}
|
||||
.ce-pre { display:block; pointer-events:none; color:var(--fg-0); position:relative; z-index:1; }
|
||||
.ce-ta {
|
||||
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
|
||||
background:transparent; color:transparent; caret-color:var(--accent);
|
||||
}
|
||||
.ce-ta::selection { background:var(--accent-ring); }
|
||||
/* jump target — the line a search result opened, orange for a second.
|
||||
Behind both layers, so the code itself keeps its own colours. */
|
||||
.ce-flash { position:absolute; left:0; right:0; min-width:100%; z-index:0; pointer-events:none; background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); animation:ce-flash-fade 1s ease forwards; }
|
||||
@keyframes ce-flash-fade { 0%{opacity:1;} 65%{opacity:1;} 100%{opacity:0;} }
|
||||
|
||||
/* wrapped buffer — no separate gutter column: the layer must fold at the pane
|
||||
width (max-content would never break), so the number rides along as a counter
|
||||
on each line block and folded rows stay blank. The textarea folds the same
|
||||
text at the same width in the same font, so both layers stay in step. */
|
||||
.code-edit.wrap .ce-inner { width:100%; }
|
||||
.code-edit.wrap .ce-pre, .code-edit.wrap .ce-ta {
|
||||
white-space:pre-wrap; overflow-wrap:break-word; word-break:normal; padding-left:60px;
|
||||
}
|
||||
.code-edit.wrap .ce-pre { counter-reset:ce-ln; }
|
||||
.ce-line { position:relative; min-height:20px; }
|
||||
.ce-line::before {
|
||||
counter-increment:ce-ln; content:counter(ce-ln);
|
||||
position:absolute; left:-56px; top:0; width:42px; text-align:right;
|
||||
font-size:13px; line-height:20px; color:var(--fg-4); user-select:none;
|
||||
}
|
||||
|
||||
/* xterm.js host (real terminals) */
|
||||
/* 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. */
|
||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:12px; background:var(--bg-0); }
|
||||
.term-xterm .xterm { height:100%; }
|
||||
/* 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-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; }
|
||||
|
||||
/* ============ Electron chrome integration ============ */
|
||||
/* macOS shows native traffic lights (titleBarStyle: hiddenInset); the prototype's
|
||||
decorative dots are hidden and the bar is made draggable. Interactive controls
|
||||
opt back out of the drag region. */
|
||||
.titlebar { -webkit-app-region: drag; padding-left: 82px; }
|
||||
/* Fullscreen: no traffic lights, so the wordmark moves back to the edge. */
|
||||
.titlebar.fullscreen { padding-left: 14px; }
|
||||
.titlebar .traffic { display: none; }
|
||||
.titlebar button,
|
||||
.titlebar input,
|
||||
.titlebar textarea,
|
||||
.titlebar .tb-actions { -webkit-app-region: no-drag; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
|
||||
/* ============ project launcher (no project open) ============ */
|
||||
/* No title bar here — only the traffic lights, and the panel sits at the top. */
|
||||
.launcher { position:fixed; inset:0; background:var(--bg-0); display:flex; align-items:flex-start; justify-content:center; padding:40px 20px 20px; }
|
||||
.launcher-drag { position:absolute; top:0; left:0; right:0; height:34px; -webkit-app-region:drag; }
|
||||
.launcher-card { width:520px; max-width:92vw; max-height:calc(100vh - 80px); display:flex; flex-direction:column; background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--shadow-overlay); overflow:hidden; -webkit-app-region:no-drag; }
|
||||
.lp-head { display:flex; align-items:center; gap:14px; padding:20px; border-bottom:1px solid var(--border); }
|
||||
.lp-head > svg, .lp-head .lp-mark { color:var(--accent); flex:0 0 auto; }
|
||||
.lp-title { display:flex; flex-direction:column; gap:5px; }
|
||||
.lp-title b { font-family:var(--mono); font-size:14px; font-weight:700; letter-spacing:.02em; color:var(--fg-0); }
|
||||
.lp-title b i { font-style:normal; color:var(--accent); animation:blink 1.1s steps(1,end) infinite; }
|
||||
.lp-title span { font-family:var(--ui); font-size:12px; color:var(--fg-3); }
|
||||
.lp-list { overflow:auto; padding:0; flex:1; min-height:0; }
|
||||
.lp-sec { padding:14px 20px 6px; font-family:var(--mono); font-weight:700; font-size:10px; line-height:1; letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
|
||||
.lp-row { height:38px; display:flex; align-items:center; gap:12px; padding:0 20px; cursor:pointer; border-left:2px solid transparent; }
|
||||
.lp-row.lp-new { height:46px; border-bottom:1px solid var(--border); }
|
||||
.lp-row:hover { background:var(--hover); }
|
||||
.lp-row.sel { background:var(--sel); border-left-color:var(--accent); }
|
||||
.lp-ic { flex:0 0 auto; width:16px; height:16px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); }
|
||||
.lp-row.sel .lp-ic { color:var(--accent); }
|
||||
.lp-new .lp-ic { color:var(--accent); }
|
||||
.lp-txt { min-width:0; display:flex; align-items:baseline; gap:10px; flex:1; }
|
||||
.lp-name { font-family:var(--ui); font-size:13px; font-weight:500; color:var(--fg-2); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.lp-row.sel .lp-name, .lp-new .lp-name { color:var(--fg-0); }
|
||||
.lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.lp-row.sel .lp-path { color:var(--fg-2); }
|
||||
.lp-foot { height:36px; 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); }
|
||||
.lp-foot b { color:var(--accent); font-weight:400; }
|
||||
|
||||
/* ============ keyboard-shortcuts (help) modal ============ */
|
||||
/* Project note (.notes.txt). The modal is as wide as the text column, so no
|
||||
empty band stays next to the scrollbar; the height fills nearly the whole
|
||||
window, with a floor for small ones, because a note is usually long. */
|
||||
.notes-modal { width:780px; max-width:100%; height:100%;
|
||||
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; }
|
||||
.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 svg { flex:0 0 auto; color:var(--accent); }
|
||||
.notes-modal .hist-title { color:var(--fg-0); font-family:var(--mono); font-weight:700; font-size:14px; line-height:1; }
|
||||
.notes-modal .notes-file { flex:1; min-width:0; font-family:var(--mono); font-size:12px; color:var(--fg-3); }
|
||||
/* Primary button: amber fill, ink text, never white. */
|
||||
.notes-modal .notes-hint { flex:0 0 auto; display:flex; align-items:center; gap:8px;
|
||||
background:var(--accent); color:#171C22; font-family:var(--ui); font-weight:500; font-size:12px;
|
||||
border-radius:var(--r-sm); padding:6px 10px; }
|
||||
.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;
|
||||
padding:18px 20px; color:var(--fg-1); font-family:var(--code-font); font-size:14px; line-height:1.7;
|
||||
caret-color:var(--accent); }
|
||||
.notes-input::placeholder { color:var(--fg-3); }
|
||||
.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); }
|
||||
|
||||
.help-modal { width:600px; max-width:92vw; 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; }
|
||||
.help-modal .pi { height:50px; flex:0 0 50px; display:flex; align-items:center; gap:12px; padding:0 18px; border-bottom:1px solid var(--border); }
|
||||
.help-modal .pi svg { flex:0 0 auto; color:var(--accent); }
|
||||
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-0); font-family:var(--mono); font-weight:700; font-size:14px; line-height:1; }
|
||||
.help-list { max-height:70vh; overflow:auto; padding:18px 20px 22px; }
|
||||
.help-sec { font-family:var(--mono); font-weight:700; font-size:10px; line-height:1; letter-spacing:.14em; text-transform:uppercase;
|
||||
color:var(--fg-3); padding:16px 0 8px; }
|
||||
.help-sec:first-child { padding-top:0; }
|
||||
.help-row { display:grid; grid-template-columns:92px 1fr; align-items:center; gap:14px; padding:6px 0; }
|
||||
.help-keys { display:flex; gap:4px; justify-content:flex-start; }
|
||||
.help-keys kbd { min-width:22px; text-align:center; }
|
||||
.help-label { font-family:var(--ui); font-size:13px; color:var(--fg-1); }
|
||||
|
||||
/* title-bar icon-only button (help ?) */
|
||||
/* Icon-only toolbar control: a 26px square with a hairline border. */
|
||||
.tb-icon { width:26px; height:26px; margin-left:6px; padding:0; justify-content:center;
|
||||
border:1px solid var(--border-2); font-family:var(--mono); font-weight:600; font-size:12px; color:var(--fg-3); }
|
||||
|
||||
/* confirm dialog (delete, etc.) */
|
||||
.confirm-modal { width:460px; max-width:92vw; background:var(--bg-2); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--shadow-overlay); padding:20px; }
|
||||
.cf-title { font-family:var(--mono); font-size:14px; font-weight:700; line-height:1; color:var(--fg-0); }
|
||||
.cf-body { margin-top:12px; font-size:12px; line-height:1.6; color:var(--fg-3); font-family:var(--mono); word-break:break-all; }
|
||||
.cf-actions { margin-top:20px; display:flex; justify-content:flex-end; gap:8px; }
|
||||
.cf-btn { display:flex; align-items:center; gap:8px; font-family:var(--ui); font-size:12px; color:var(--fg-2); background:transparent; border:1px solid var(--border-2); border-radius:var(--r-sm); padding:6px 10px; cursor:pointer; }
|
||||
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.cf-yes { background:var(--accent); color:#171C22; border-color:transparent; font-weight:500; }
|
||||
.cf-yes:hover { background:var(--accent); color:#171C22; }
|
||||
.cf-yes kbd { color:#171C22; border-color:rgba(23,28,34,.25); }
|
||||
.cf-yes.danger { background:var(--accent-deep); color:#F4F5F4; }
|
||||
.cf-yes.danger:hover { background:var(--accent-deep); }
|
||||
.cf-yes.danger kbd { color:#F4F5F4; border-color:rgba(244,245,244,.4); }
|
||||
|
||||
/* search: active result column + file-name selection */
|
||||
.sc-head .col-kbd { margin-left:auto; }
|
||||
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
||||
.sc-left.active .sc-head .sc-ct, .sc-right.active .sc-head .sc-ct, .sc-infile.active .sc-head .sc-ct {
|
||||
color:#171C22; background:var(--accent); padding:3px 6px; }
|
||||
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-dim); }
|
||||
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
||||
61
src/renderer/src/symbols.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/* Which PHP class names this project declares.
|
||||
*
|
||||
* A module-level Set rather than React state: `HL.hlText` marks the tokens
|
||||
* while it builds the HTML, and it is called from four memos that have no
|
||||
* business carrying a prop for it. Components that render code subscribe with
|
||||
* `useSymbols()` and put the version in their memo deps, so highlighting
|
||||
* recomputes once when the list lands — and never again while it is unchanged.
|
||||
*
|
||||
* The list is fetched on demand and the main process builds it lazily, so
|
||||
* nothing here is on the startup path. Until it arrives the Set is empty, which
|
||||
* simply means no token is underlined yet.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { rlog } from './log'
|
||||
|
||||
let known = new Set<string>()
|
||||
let version = 0
|
||||
let pending: Promise<void> | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function emit(): void {
|
||||
version++
|
||||
listeners.forEach((l) => l())
|
||||
}
|
||||
|
||||
export function isKnownSymbol(name: string): boolean {
|
||||
return known.has(name)
|
||||
}
|
||||
|
||||
export function symbolsVersion(): number {
|
||||
return version
|
||||
}
|
||||
|
||||
/** Ask main for the declared names. Cheap to call repeatedly: one flight at a
|
||||
* time, and a `force` refresh after the project changed. */
|
||||
export function loadSymbols(force = false): void {
|
||||
const bridge = window.helder
|
||||
if (!bridge || pending) return
|
||||
if (known.size > 0 && !force) return
|
||||
pending = bridge.symbols.names()
|
||||
.then((names) => { known = new Set(names); emit() })
|
||||
.catch((e) => { rlog.error('symbols', 'name list failed', e) })
|
||||
.finally(() => { pending = null })
|
||||
}
|
||||
|
||||
/** Drop the list so the next `loadSymbols()` refetches (a file changed). */
|
||||
export function invalidateSymbols(): void {
|
||||
known = new Set()
|
||||
emit()
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void): () => void {
|
||||
listeners.add(cb)
|
||||
return () => { listeners.delete(cb) }
|
||||
}
|
||||
|
||||
/** Re-renders the caller when the name list changes. Returns the version, for
|
||||
* the dependency array of a highlight memo. */
|
||||
export function useSymbols(): number {
|
||||
return useSyncExternalStore(subscribe, symbolsVersion, symbolsVersion)
|
||||
}
|
||||
239
src/renderer/src/terminals.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
/* Real terminals: xterm.js in the renderer bound to a node-pty PTY in main.
|
||||
* Agent pane = a shell that auto-launches `claude`; bottom pane = a plain shell.
|
||||
* "Pass on to Agent" arrives via the `agentPaste` window event and is written to
|
||||
* the agent PTY wrapped in bracketed paste (\x1b[200~ … \x1b[201~) so the CLI
|
||||
* treats it as pasted, UNSUBMITTED input — leaving the caret on a fresh line so
|
||||
* references can be stacked before the user hits Enter. */
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { ContextMenu } from './overlays'
|
||||
import type { Menu } from './overlays'
|
||||
import { Icon } from './components'
|
||||
import { useProject } from './project'
|
||||
import type { HelderConfig } from './types'
|
||||
|
||||
let _lid = 0
|
||||
export const lid = (): number => ++_lid
|
||||
|
||||
const MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
|
||||
|
||||
/* Everything visual comes from `terminal` in config.json — palette, font,
|
||||
* cursor, scrollback — so the console is themed from the same file as the rest
|
||||
* of the project. Font family and size fall back to the CSS vars (--code-font /
|
||||
* --term-size in theme.css) when the config leaves them null, which keeps the
|
||||
* old "one place for the code font" rule intact. */
|
||||
function xtermOptions(cfg: HelderConfig['terminal']): {
|
||||
fontFamily: string; fontSize: number; lineHeight: number; letterSpacing: number
|
||||
cursorStyle: 'bar' | 'block' | 'underline'; cursorBlink: boolean
|
||||
drawBoldTextInBrightColors: boolean; scrollback: number; macOptionIsMeta: boolean
|
||||
theme: HelderConfig['terminal']['theme']
|
||||
} {
|
||||
const css = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
fontFamily: cfg.fontFamily || css.getPropertyValue('--code-font').trim() || MONO,
|
||||
fontSize: cfg.fontSize || parseFloat(css.getPropertyValue('--term-size')) || 12,
|
||||
lineHeight: cfg.lineHeight,
|
||||
letterSpacing: cfg.letterSpacing,
|
||||
cursorStyle: cfg.cursorStyle,
|
||||
cursorBlink: cfg.cursorBlink,
|
||||
drawBoldTextInBrightColors: cfg.boldIsBright,
|
||||
scrollback: cfg.scrollback,
|
||||
macOptionIsMeta: cfg.optionIsMeta,
|
||||
theme: cfg.theme,
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const termRef = useRef<XTerm | null>(null)
|
||||
const fitRef = useRef<(() => void) | null>(null)
|
||||
// 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
|
||||
// config.json restyles the running session instead of restarting it.
|
||||
const cfg = useProject().config.terminal
|
||||
const cfgRef = useRef(cfg); cfgRef.current = cfg
|
||||
const [, setLive] = useState(kind === 'agent')
|
||||
const [menu, setMenu] = useState<Menu | null>(null)
|
||||
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
|
||||
// land on its own line, so we prepend a newline — UNLESS the composer is empty
|
||||
// (no leading blank line). We can't read the CLI's input buffer, so we infer:
|
||||
// typing a printable char marks it dirty, pressing Enter (submit) clears it.
|
||||
const composerDirty = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = window.helder
|
||||
const host = hostRef.current
|
||||
if (!host) return
|
||||
|
||||
const term = new XTerm({ ...xtermOptions(cfgRef.current), allowProposedApi: true })
|
||||
termRef.current = term
|
||||
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.
|
||||
if (bridge) {
|
||||
term.onSelectionChange(() => {
|
||||
const sel = term.getSelection()
|
||||
if (sel) bridge.clipboard.writeText(sel)
|
||||
})
|
||||
}
|
||||
// ⌘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 id = -1
|
||||
let offData = (): void => {}
|
||||
let offExit = (): void => {}
|
||||
|
||||
function onPaste(e: Event): void {
|
||||
if (kind !== 'agent' || !bridge || id < 0) return
|
||||
const text = (e as CustomEvent<string>).detail
|
||||
// Own line for the reference; skip the leading newline on an empty composer.
|
||||
const lead = composerDirty.current ? '\n' : ''
|
||||
bridge.pty.write(id, '\x1b[200~' + lead + text + '\x1b[201~')
|
||||
composerDirty.current = true
|
||||
term.focus()
|
||||
}
|
||||
|
||||
if (bridge) {
|
||||
bridge.pty.create(kind, term.cols, term.rows).then((newId) => {
|
||||
if (disposed) { if (newId >= 0) bridge.pty.kill(newId); return }
|
||||
id = newId
|
||||
if (id < 0) {
|
||||
term.write('\r\n \x1b[33mPTY unavailable\x1b[0m — run `npm run rebuild`, then restart.\r\n')
|
||||
setLive(false)
|
||||
return
|
||||
}
|
||||
offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) })
|
||||
offExit = bridge.pty.onExit((tid) => { if (tid === id) { term.write('\r\n\x1b[90m[process exited]\x1b[0m\r\n'); setLive(false) } })
|
||||
term.onData((d) => {
|
||||
// Track composer emptiness for the agent pane: Enter submits (→ empty),
|
||||
// a printable keystroke means there's content on the current line.
|
||||
if (kind === 'agent') {
|
||||
if (d.includes('\r')) composerDirty.current = false
|
||||
else if (d >= ' ') composerDirty.current = true
|
||||
}
|
||||
bridge.pty.write(id, d)
|
||||
})
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
term.write(' \x1b[90mTerminal needs the Electron host (node-pty); not available in browser preview.\x1b[0m\r\n')
|
||||
setLive(false)
|
||||
}
|
||||
|
||||
// Fitting resizes xterm's own DOM, which the observer sees straight away.
|
||||
// Done inline that is a write during observation delivery, and the browser
|
||||
// reports "ResizeObserver loop completed with undelivered notifications" for
|
||||
// every frame of a window drag. Deferring to the next frame keeps the write
|
||||
// out of the delivery, and coalesces a drag into one fit per frame.
|
||||
let frame = 0
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (frame) return
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
try { refit() } catch { /* host not measured yet */ }
|
||||
})
|
||||
})
|
||||
ro.observe(host)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
ro.disconnect()
|
||||
offData()
|
||||
offExit()
|
||||
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
|
||||
if (bridge && id >= 0) bridge.pty.kill(id)
|
||||
termRef.current = null
|
||||
fitRef.current = null
|
||||
term.dispose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Live restyle: config.json (or theme.css) changed, so push the new options
|
||||
// into the running terminal and refit — the buffer and the PTY are untouched.
|
||||
useEffect(() => {
|
||||
const term = termRef.current
|
||||
if (!term) return
|
||||
Object.assign(term.options, xtermOptions(cfg))
|
||||
fitRef.current?.()
|
||||
}, [cfg])
|
||||
|
||||
// Both panes (D1 agent + D2 shell): right-click → copy selection / paste from clipboard.
|
||||
function onContextMenu(e: React.MouseEvent): void {
|
||||
const bridge = window.helder
|
||||
const term = termRef.current
|
||||
if (!bridge || !term) return
|
||||
e.preventDefault()
|
||||
const items: Menu['items'] = []
|
||||
if (term.hasSelection()) {
|
||||
items.push({ icon: Icon.copy(), label: 'Copy', onClick: () => bridge.clipboard.writeText(term.getSelection()) })
|
||||
}
|
||||
items.push({
|
||||
icon: Icon.paste(), label: 'Paste', onClick: () => {
|
||||
const text = bridge.clipboard.readText()
|
||||
if (text) term.paste(text)
|
||||
term.focus()
|
||||
},
|
||||
})
|
||||
setMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="term-pane" style={{ flex: 1, minHeight: 0, background: cfg.theme.background }}
|
||||
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
|
||||
onContextMenu={onContextMenu}>
|
||||
<div className="term-xterm" ref={hostRef} />
|
||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
src/renderer/src/types.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
export type GitStatus = 'A' | 'M' | 'D' | 'R' | 'U'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
type: 'dir' | 'file'
|
||||
path: string
|
||||
open?: boolean
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
export interface DiffRow {
|
||||
sign: string
|
||||
oldNo: number | null
|
||||
newNo: number | null
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SideLine {
|
||||
no: number
|
||||
text: string
|
||||
mark?: 'add' | 'del' | null
|
||||
}
|
||||
|
||||
export interface SplitRow {
|
||||
l: SideLine | null
|
||||
r: SideLine | null
|
||||
}
|
||||
|
||||
export interface Diff {
|
||||
rows: DiffRow[]
|
||||
left: SideLine[]
|
||||
right: SideLine[]
|
||||
split: SplitRow[]
|
||||
add: number
|
||||
del: number
|
||||
deleted: boolean
|
||||
added: boolean
|
||||
original: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface Change {
|
||||
path: string
|
||||
status: GitStatus
|
||||
add: number
|
||||
del: number
|
||||
deleted: boolean
|
||||
/** True when this row is the staged half of the file. A file that is staged
|
||||
* and then edited again produces two rows, one in each group. */
|
||||
staged: boolean
|
||||
/** Row identity. Path alone is no longer unique — see `staged`. */
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Which half of a file's changes a diff view shows. `staged` is HEAD vs the
|
||||
* index, `unstaged` is the index vs disk. Absent means the whole file: HEAD vs
|
||||
* disk, which is what Original and Actual always show. */
|
||||
export type DiffSide = 'staged' | 'unstaged'
|
||||
|
||||
/** Row id for a git change. Keeps the two halves of one file apart. */
|
||||
export function rowId(path: string, staged: boolean): string {
|
||||
return (staged ? 's:' : 'w:') + path
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
name: string
|
||||
branch: string
|
||||
files: Record<string, string>
|
||||
originals: Record<string, string>
|
||||
tree: FileNode
|
||||
diffs: Record<string, Diff>
|
||||
changes: Change[]
|
||||
}
|
||||
|
||||
/** A line descriptor consumed by the editor PaneView. */
|
||||
export interface ViewLine {
|
||||
no: number | null
|
||||
text: string
|
||||
sign?: string
|
||||
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'
|
||||
/** Soft wrap of long lines: never, always, or only in Markdown files. */
|
||||
export type WordWrap = 'off' | 'on' | 'markdown'
|
||||
|
||||
/** xterm's colour table. Every value is a CSS colour; the selection entries
|
||||
* may carry alpha, the rest may not. */
|
||||
export interface TerminalTheme {
|
||||
background: string; foreground: string; cursor: string; cursorAccent: string
|
||||
selectionBackground: string; selectionInactiveBackground: string
|
||||
black: string; red: string; green: string; yellow: string
|
||||
blue: string; magenta: string; cyan: string; white: string
|
||||
brightBlack: string; brightRed: string; brightGreen: string; brightYellow: string
|
||||
brightBlue: string; brightMagenta: string; brightCyan: string; brightWhite: string
|
||||
}
|
||||
|
||||
/** Effective project settings (mirrors src/main/config.ts). */
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: {
|
||||
/** Login shell for both panes. null = $SHELL. */
|
||||
shell: string | null
|
||||
/** null = follow the CSS vars (--code-font / --term-size in theme.css). */
|
||||
fontFamily: string | null
|
||||
fontSize: number | null
|
||||
lineHeight: number
|
||||
letterSpacing: number
|
||||
cursorStyle: 'bar' | 'block' | 'underline'
|
||||
cursorBlink: boolean
|
||||
/** Paint bold text in the bright colour. Off keeps bold in its own hue,
|
||||
* which stops a CLI's bold labels from washing out. */
|
||||
boldIsBright: boolean
|
||||
scrollback: number
|
||||
/** macOS: send Option as Meta. Needed for a CLI's ⌥↵ binding; it also stops
|
||||
* Option from typing accented characters, so it is off by default. */
|
||||
optionIsMeta: boolean
|
||||
theme: TerminalTheme
|
||||
}
|
||||
session: { restoreOnLaunch: boolean }
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
|
||||
files: { exclude: [], followGitignore: true },
|
||||
terminal: {
|
||||
shell: null,
|
||||
fontFamily: null,
|
||||
fontSize: null,
|
||||
lineHeight: 1.7,
|
||||
letterSpacing: 0,
|
||||
cursorStyle: 'bar',
|
||||
cursorBlink: true,
|
||||
boldIsBright: false,
|
||||
scrollback: 8000,
|
||||
optionIsMeta: false,
|
||||
// The app's own palette: the editor ground, amber on the caret and the
|
||||
// selection, and the six muted syntax colours on the ANSI table, so a diff
|
||||
// in the terminal reads like a diff in the editor. Red is amber-deep and
|
||||
// green is teal — the same pair the diff views use. A terminal still needs
|
||||
// eight distinguishable slots, so blue and cyan take two cool tones that
|
||||
// stay inside the muted register.
|
||||
theme: {
|
||||
background: '#101720',
|
||||
foreground: '#E4E7E6',
|
||||
cursor: '#E8913A',
|
||||
cursorAccent: '#101720',
|
||||
selectionBackground: 'rgba(232,145,58,0.22)',
|
||||
selectionInactiveBackground: 'rgba(232,145,58,0.12)',
|
||||
black: '#232C39', red: '#C4741F', green: '#8FBFB4', yellow: '#F0B476',
|
||||
blue: '#8FA9C4', magenta: '#C3A6CE', cyan: '#8FC4C4', white: '#BAC0C0',
|
||||
brightBlack: '#6C7783', brightRed: '#E8913A', brightGreen: '#A6D2C7', brightYellow: '#F5C79A',
|
||||
brightBlue: '#A9BFD6', brightMagenta: '#D6BFDF', brightCyan: '#A9D6D6', brightWhite: '#F4F5F4',
|
||||
},
|
||||
},
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
/** One class/interface/trait/enum declaration in the project. */
|
||||
export interface SymbolDef {
|
||||
name: string; path: string; line: number; kind: string
|
||||
/** The file's namespace, for the fully qualified name. */
|
||||
ns: string
|
||||
/** `extends` on the declaration line. */
|
||||
parents: string[]
|
||||
/** `implements` on the declaration line. */
|
||||
interfaces: string[]
|
||||
/** Traits mixed in by an indented `use` in the body. */
|
||||
traits: string[]
|
||||
}
|
||||
/** References to one name, grouped by file. */
|
||||
export interface RefGroup { path: string; hits: { no: number; ln: string; ix: number }[] }
|
||||
/** What a ⌘-click on a class name returns: where it lives, and where it is used. */
|
||||
export interface SymbolLookup { name: string; defs: SymbolDef[]; refs: RefGroup[]; refCount: number }
|
||||
45
sync_helder.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync_helder.sh — build Helder (arm64), install it into ~/Applications, and
|
||||
# drop a stable-named Helder.dmg alongside it.
|
||||
#
|
||||
# ./sync_helder.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Run from the repo root (where this script lives), whatever the caller's cwd is.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APPS_DIR="$HOME/Applications"
|
||||
APP_DEST="$APPS_DIR/Helder.app"
|
||||
DMG_DEST="$APPS_DIR/Helder.dmg"
|
||||
|
||||
echo "▶ Building Helder (arm64)…"
|
||||
npm run dist:mac
|
||||
|
||||
# Locate the freshly built artifacts.
|
||||
APP_SRC="dist/mac-arm64/Helder.app"
|
||||
DMG_SRC="$(ls -t dist/Helder-*-arm64.dmg 2>/dev/null | head -n1 || true)"
|
||||
|
||||
[ -d "$APP_SRC" ] || { echo "✗ Build output not found: $APP_SRC"; exit 1; }
|
||||
[ -n "$DMG_SRC" ] && [ -f "$DMG_SRC" ] || { echo "✗ No built .dmg found in dist/"; exit 1; }
|
||||
|
||||
mkdir -p "$APPS_DIR"
|
||||
|
||||
# Quit a running copy so we can replace the bundle cleanly.
|
||||
osascript -e 'tell application "Helder" to quit' >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
echo "▶ Installing app → $APP_DEST"
|
||||
rm -rf "$APP_DEST"
|
||||
cp -R "$APP_SRC" "$APP_DEST"
|
||||
# Unsigned ad-hoc build: clear the quarantine flag so it launches without the
|
||||
# Gatekeeper "unidentified developer" prompt.
|
||||
xattr -dr com.apple.quarantine "$APP_DEST" 2>/dev/null || true
|
||||
|
||||
echo "▶ Copying installer → $DMG_DEST"
|
||||
cp -f "$DMG_SRC" "$DMG_DEST"
|
||||
|
||||
echo "✓ Done."
|
||||
echo " • Installed: $APP_DEST (launch from Spotlight)"
|
||||
echo " • Installer: $DMG_DEST (renamed from $(basename "$DMG_SRC"))"
|
||||
128
test/app-interactions.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, 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'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
return render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
}
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
// The tree opens fully collapsed, so expand each ancestor folder before reaching
|
||||
// a nested file. Each folder is clicked exactly once (a second click collapses).
|
||||
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
|
||||
for (const name of folders) {
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', name)
|
||||
if (!r) throw new Error(`folder ${name} not ready`)
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Pass on to Agent', () => {
|
||||
it('inserts "<note> => <path:line>" via the agentPaste event', async () => {
|
||||
const received: string[] = []
|
||||
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', handler)
|
||||
try {
|
||||
const c = renderApp()
|
||||
await expandTo(c, 'public', 'assets')
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow)
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
fireEvent.contextMenu(ta)
|
||||
const pass = await waitFor(() => {
|
||||
const item = find(c, '.ctx-item', 'Pass on reference')
|
||||
if (!item) throw new Error('menu not open')
|
||||
return item
|
||||
})
|
||||
fireEvent.click(pass)
|
||||
const input = await waitFor(() => {
|
||||
const i = c.querySelector<HTMLInputElement>('.pass-input')
|
||||
if (!i) throw new Error('popup not open')
|
||||
return i
|
||||
})
|
||||
fireEvent.change(input, { target: { value: 'look here' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(received.length).toBeGreaterThan(0))
|
||||
expect(received[0]).toBe('look here => public/assets/store.js:1')
|
||||
} finally {
|
||||
window.removeEventListener('agentPaste', handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stage + commit', () => {
|
||||
it('stages a file and commits via Shift+Enter (no commit button)', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
|
||||
// the commit button is intentionally gone — committing is keyboard-only
|
||||
expect(c.querySelector('.commit-btn')).toBeNull()
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Explorer background menu', () => {
|
||||
it('right-clicking the empty space offers New file + Show in Finder only', async () => {
|
||||
const c = renderApp()
|
||||
const body = await waitFor(() => {
|
||||
const b = c.querySelector<HTMLElement>('.tree-body')
|
||||
if (!b || !b.querySelector('.tree-row')) throw new Error('tree not ready')
|
||||
return b
|
||||
})
|
||||
fireEvent.contextMenu(body)
|
||||
const labels = await waitFor(() => {
|
||||
const items = Array.from(c.querySelectorAll<HTMLElement>('.ctx-item')).map((el) => el.textContent)
|
||||
if (!items.length) throw new Error('menu not open')
|
||||
return items
|
||||
})
|
||||
expect(labels).toEqual(['New file', 'Show in Finder'])
|
||||
// the root is never renameable or deletable — that would mean the project
|
||||
expect(labels.some((l) => l?.includes('Delete'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a row right-click still shows the file menu, not the root one', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', 'package.json')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.contextMenu(row)
|
||||
await waitFor(() => expect(find(c, '.ctx-item', 'Rename file')).toBeTruthy())
|
||||
expect(find(c, '.ctx-item', 'Copy file name')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
108
test/app.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
// The terminals use xterm + ResizeObserver, which don't belong in a jsdom unit
|
||||
// test. Stub them — the rest of the workbench renders for real against the mock.
|
||||
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'
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom gaps used by the tree/tab code.
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
const { container } = render(
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>,
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
// The tree opens fully collapsed, so expand each ancestor folder before reaching
|
||||
// a nested file. Each folder is clicked exactly once (a second click collapses).
|
||||
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
|
||||
for (const name of folders) {
|
||||
const row = await waitFor(() => {
|
||||
const r = rowWithText(c, '.tree-row', name)
|
||||
if (!r) throw new Error(`folder ${name} not ready`)
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
}
|
||||
}
|
||||
|
||||
describe('App (mock data, jsdom)', () => {
|
||||
it('renders the four-column workbench with the git change list', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
expect(c.querySelector('.workbench')).toBeTruthy()
|
||||
expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
|
||||
expect(c.querySelector('.tree-body')).toBeTruthy() // explorer
|
||||
// no file open yet
|
||||
expect(c.textContent).toContain('No file open')
|
||||
})
|
||||
|
||||
it('opens a changed file from the git panel into the diff view', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = rowWithText(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('row not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
// no tabs anymore — the file opens straight into the editor's diff toolbar,
|
||||
// and its path shows in the title-bar breadcrumb.
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
||||
expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('makes an edited buffer dirty (breadcrumb dot)', async () => {
|
||||
const c = renderApp()
|
||||
await expandTo(c, 'public', 'assets')
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = rowWithText(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow) // unchanged file → editable "code" mode
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
expect(c.querySelector('.tb-dirty')).toBeNull()
|
||||
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
||||
await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('searches file contents from the search modal', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
const searchBtn = rowWithText(c, '.tb-btn', 'Search')!
|
||||
fireEvent.click(searchBtn)
|
||||
const modal = await waitFor(() => {
|
||||
const m = c.querySelector('.search-modal')
|
||||
if (!m) throw new Error('modal not open')
|
||||
return m as HTMLElement
|
||||
})
|
||||
const input = within(modal).getByPlaceholderText(/Search this file/i)
|
||||
fireEvent.change(input, { target: { value: 'balance' } })
|
||||
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
|
||||
})
|
||||
})
|
||||
70
test/config.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
|
||||
import { DEFAULT_CONFIG as RENDERER_DEFAULTS } from '../src/renderer/src/types'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
it('creates config.default.json + theme.css and yields defaults for a fresh project', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def).toEqual(DEFAULTS)
|
||||
const theme = await readFile(join(dir, '.helder/theme.css'), 'utf8')
|
||||
expect(theme).toContain('--code-font')
|
||||
expect(getConfig().ai.command).toBe('claude')
|
||||
expect(getThemeCss()).toContain('Helder theme')
|
||||
})
|
||||
|
||||
it('deep-merges a sparse config.json over defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'),
|
||||
JSON.stringify({ ai: { command: 'claude --model opus' }, editor: { tabSize: 2 } }))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const c = getConfig()
|
||||
expect(c.ai.command).toBe('claude --model opus') // overridden
|
||||
expect(c.ai.autoLaunch).toBe(true) // default kept
|
||||
expect(c.editor.tabSize).toBe(2) // overridden
|
||||
expect(c.editor.autoSave).toBe(false) // default kept
|
||||
expect(c.git.confirmDiscard).toBe(true) // default kept
|
||||
})
|
||||
|
||||
it('always regenerates config.default.json with full built-in defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'), JSON.stringify({ ai: { command: 'x' } }))
|
||||
await resolveConfig(dir)
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def.ai.command).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not overwrite an existing theme.css', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/theme.css'), ':root{--accent:#ff0000}')
|
||||
await resolveConfig(dir)
|
||||
expect(getThemeCss()).toContain('#ff0000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal defaults', () => {
|
||||
it('mirrors the main-process schema in the renderer', () => {
|
||||
// Two copies of one schema drift silently; the terminal block is the one
|
||||
// the user edits by hand, so it is worth asserting they still agree.
|
||||
expect(RENDERER_DEFAULTS.terminal).toEqual(DEFAULTS.terminal)
|
||||
})
|
||||
it('carries a full ANSI table, so a partial config.json still resolves', () => {
|
||||
const t = DEFAULTS.terminal.theme
|
||||
for (const key of ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'] as const) {
|
||||
expect(t[key]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(t[`bright${key[0].toUpperCase()}${key.slice(1)}` as keyof typeof t]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
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
@@ -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)
|
||||
})
|
||||
})
|
||||
101
test/diff.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDiff, makeDiff } from '../src/renderer/src/diff'
|
||||
|
||||
describe('buildDiff', () => {
|
||||
it('reports no changes for identical text', () => {
|
||||
const d = buildDiff('a\nb\nc\n', 'a\nb\nc\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.rows.every((r) => r.sign === ' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts a single changed line as one add + one del', () => {
|
||||
const d = buildDiff('a\nb\nc', 'a\nB\nc')
|
||||
expect(d.add).toBe(1)
|
||||
expect(d.del).toBe(1)
|
||||
const signs = d.rows.map((r) => r.sign).join('')
|
||||
expect(signs).toContain('-')
|
||||
expect(signs).toContain('+')
|
||||
})
|
||||
|
||||
it('treats an empty original as all additions (new file)', () => {
|
||||
const d = buildDiff('', 'x\ny')
|
||||
expect(d.add).toBe(2)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.left).toHaveLength(0)
|
||||
expect(d.right).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats an empty updated as all deletions (deleted file)', () => {
|
||||
const d = buildDiff('x\ny\nz', '')
|
||||
expect(d.del).toBe(3)
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.right).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks the changed line on both sides', () => {
|
||||
const d = buildDiff('keep\nold\nkeep', 'keep\nnew\nkeep')
|
||||
expect(d.left.find((l) => l.text === 'old')?.mark).toBe('del')
|
||||
expect(d.right.find((l) => l.text === 'new')?.mark).toBe('add')
|
||||
expect(d.left.find((l) => l.text === 'keep')?.mark).toBeNull()
|
||||
})
|
||||
|
||||
it('aligns split rows: same lines pair, changes stack into the gap', () => {
|
||||
const d = buildDiff('a\nold\nb', 'a\nnew\nb')
|
||||
// every split row has at least one side
|
||||
expect(d.split.every((r) => r.l || r.r)).toBe(true)
|
||||
// the matched 'a' and 'b' lines pair on both sides
|
||||
const paired = d.split.filter((r) => r.l && r.r && r.l.text === r.r.text)
|
||||
expect(paired.map((r) => r.l!.text)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('ignores a single trailing newline difference', () => {
|
||||
const d = buildDiff('a\nb', 'a\nb\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeDiff', () => {
|
||||
it('flags added / deleted and carries the text pair', () => {
|
||||
const added = makeDiff('A', '', 'hi')
|
||||
expect(added.added).toBe(true)
|
||||
expect(added.deleted).toBe(false)
|
||||
expect(added.original).toBe('')
|
||||
expect(added.updated).toBe('hi')
|
||||
|
||||
const deleted = makeDiff('D', 'bye', '')
|
||||
expect(deleted.deleted).toBe(true)
|
||||
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)
|
||||
})
|
||||
})
|
||||
157
test/editor-modes.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { act, 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'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
async function openChanged(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
return c
|
||||
}
|
||||
|
||||
describe('Editor view modes', () => {
|
||||
it('Actual mode is an editable buffer; Original is read-only', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||
|
||||
fireEvent.click(find(c, '.seg button', 'Original')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
|
||||
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a git row opens the file in Actual, not in the full-screen Diff', async () => {
|
||||
const c = await openChanged()
|
||||
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())
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
// Default config wraps Markdown only (editor.wordWrap: 'markdown'). A wrapped
|
||||
// buffer drops the fixed gutter column for one block per line, so the folded
|
||||
// rows carry no number.
|
||||
describe('Word wrap', () => {
|
||||
async function openTree(c: HTMLElement, name: string): Promise<void> {
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.tree-row')).find((el) => el.textContent?.includes(name))
|
||||
if (!r) throw new Error(`${name} not ready`)
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
}
|
||||
|
||||
it('folds long lines in Markdown, one numbered block per source line', async () => {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await openTree(c, 'README.md')
|
||||
const edit = await waitFor(() => {
|
||||
const el = c.querySelector<HTMLElement>('.code-edit')
|
||||
if (!el) throw new Error('editor not ready')
|
||||
return el
|
||||
})
|
||||
expect(edit.classList.contains('wrap')).toBe(true)
|
||||
expect(edit.querySelector('.ce-gutter')).toBeNull()
|
||||
const ta = edit.querySelector<HTMLTextAreaElement>('.ce-ta')!
|
||||
expect(ta.getAttribute('wrap')).toBe('soft')
|
||||
expect(edit.querySelectorAll('.ce-line').length).toBe(ta.value.split('\n').length)
|
||||
})
|
||||
|
||||
it('leaves other file types on the scrolling gutter layout', async () => {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await openTree(c, 'composer.json')
|
||||
const edit = await waitFor(() => {
|
||||
const el = c.querySelector<HTMLElement>('.code-edit')
|
||||
if (!el) throw new Error('editor not ready')
|
||||
return el
|
||||
})
|
||||
expect(edit.classList.contains('wrap')).toBe(false)
|
||||
expect(edit.querySelector('.ce-gutter')).toBeTruthy()
|
||||
expect(edit.querySelector('.ce-line')).toBeNull()
|
||||
})
|
||||
})
|
||||
83
test/editor-tab.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, 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'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
// Open a changed file and switch to the writable buffer.
|
||||
async function openEditor(): Promise<HTMLTextAreaElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||
return await waitFor(() => {
|
||||
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!ta) throw new Error('no buffer')
|
||||
return ta
|
||||
})
|
||||
}
|
||||
|
||||
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
|
||||
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
|
||||
}
|
||||
|
||||
describe('Tab in the code editor', () => {
|
||||
it('inserts four spaces at the caret instead of moving focus', async () => {
|
||||
const ta = await openEditor()
|
||||
const before = ta.value
|
||||
ta.setSelectionRange(0, 0)
|
||||
|
||||
// fireEvent returns false when a handler called preventDefault, which is
|
||||
// what stops the browser tabbing focus over to the agent column.
|
||||
expect(tab(ta)).toBe(false)
|
||||
await waitFor(() => expect(ta.value).toBe(' ' + before))
|
||||
})
|
||||
|
||||
it('indents every line a multi-line selection touches', async () => {
|
||||
const ta = await openEditor()
|
||||
const lines = ta.value.split('\n')
|
||||
// Select from inside line 1 to inside line 2.
|
||||
ta.setSelectionRange(1, lines[0].length + 2)
|
||||
|
||||
tab(ta)
|
||||
await waitFor(() => {
|
||||
const now = ta.value.split('\n')
|
||||
expect(now[0]).toBe(' ' + lines[0])
|
||||
expect(now[1]).toBe(' ' + lines[1])
|
||||
expect(now[2]).toBe(lines[2])
|
||||
})
|
||||
})
|
||||
|
||||
it('Shift+Tab outdents the current line', async () => {
|
||||
const ta = await openEditor()
|
||||
ta.setSelectionRange(0, 0)
|
||||
tab(ta)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
|
||||
|
||||
ta.setSelectionRange(6, 6)
|
||||
expect(tab(ta, true)).toBe(false)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
|
||||
})
|
||||
})
|
||||
197
test/fs.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { buildTreeFromPaths, createProjectDir, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from '../src/main/fs-service'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await mkdir(join(d, 'src'), { recursive: true })
|
||||
await mkdir(join(d, 'node_modules/pkg'), { recursive: true })
|
||||
await mkdir(join(d, '.git'), { recursive: true })
|
||||
await writeFile(join(d, 'src', 'a.ts'), 'export const a = 1\n')
|
||||
await writeFile(join(d, 'README.md'), '# hi\n')
|
||||
await writeFile(join(d, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
|
||||
await writeFile(join(d, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||
await writeFile(join(d, 'logo.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x99]))
|
||||
return d
|
||||
}
|
||||
|
||||
describe('readTree', () => {
|
||||
it('lists dirs before files, ignoring node_modules and .git', async () => {
|
||||
dir = await fixture()
|
||||
const tree = await readTree(dir)
|
||||
const top = (tree.children || []).map((c) => c.name)
|
||||
expect(top).not.toContain('node_modules')
|
||||
expect(top).not.toContain('.git')
|
||||
expect(top).toContain('src')
|
||||
// dirs first
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
|
||||
})
|
||||
|
||||
it('shows an empty folder (no files yet) that rg --files can not emit', async () => {
|
||||
dir = await fixture()
|
||||
await mkdir(join(dir, 'empty'), { recursive: true })
|
||||
await mkdir(join(dir, 'src', 'nested', 'deep'), { recursive: true }) // file-empty nested chain
|
||||
const tree = await readTree(dir)
|
||||
const top = (tree.children || []).map((c) => c.name)
|
||||
expect(top).toContain('empty')
|
||||
const empty = (tree.children || []).find((c) => c.name === 'empty')!
|
||||
expect(empty.type).toBe('dir')
|
||||
// the nested empty chain shows under the (file-bearing) src folder
|
||||
const src = (tree.children || []).find((c) => c.name === 'src')!
|
||||
const nested = (src.children || []).find((c) => c.name === 'nested')!
|
||||
expect(nested.type).toBe('dir')
|
||||
expect((nested.children || []).map((c) => c.name)).toEqual(['deep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDirChildren', () => {
|
||||
it('returns a single folder\'s children (root and subdir), ignoring node_modules', async () => {
|
||||
dir = await fixture()
|
||||
const top = (await readDirChildren(dir, '')).map((c) => c.name)
|
||||
expect(top).toContain('src')
|
||||
expect(top).toContain('README.md')
|
||||
expect(top).not.toContain('node_modules')
|
||||
const src = await readDirChildren(dir, 'src')
|
||||
expect(src.map((c) => c.name)).toEqual(['a.ts'])
|
||||
expect(src[0].path).toBe('src/a.ts')
|
||||
})
|
||||
|
||||
it('shows an empty subfolder created since the initial tree read', async () => {
|
||||
dir = await fixture()
|
||||
await mkdir(join(dir, 'src', 'fresh'), { recursive: true })
|
||||
const src = await readDirChildren(dir, 'src')
|
||||
const fresh = src.find((c) => c.name === 'fresh')
|
||||
expect(fresh?.type).toBe('dir')
|
||||
expect(fresh?.path).toBe('src/fresh')
|
||||
})
|
||||
|
||||
it('reflects files added/removed since the initial tree read', async () => {
|
||||
dir = await fixture()
|
||||
await writeFile(join(dir, 'src', 'b.ts'), 'export const b = 2\n')
|
||||
await rm(join(dir, 'src', 'a.ts'))
|
||||
expect((await readDirChildren(dir, 'src')).map((c) => c.name)).toEqual(['b.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAll', () => {
|
||||
it('indexes text files, skipping ignored dirs and binary files', async () => {
|
||||
dir = await fixture()
|
||||
const files = await readAll(dir)
|
||||
const keys = Object.keys(files)
|
||||
expect(keys).toContain('src/a.ts')
|
||||
expect(keys).toContain('README.md')
|
||||
expect(keys.some((k) => k.includes('node_modules'))).toBe(false)
|
||||
expect(keys).not.toContain('logo.bin') // binary skipped
|
||||
expect(files['src/a.ts']).toContain('export const a')
|
||||
})
|
||||
|
||||
it('shows .gitignored files in a repo (files.followGitignore default off)', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await simpleGit(dir).init()
|
||||
await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
|
||||
await writeFile(join(dir, 'secret.txt'), 'shh')
|
||||
await writeFile(join(dir, 'keep.txt'), 'ok')
|
||||
const keys = Object.keys(await readAll(dir))
|
||||
expect(keys).toContain('keep.txt')
|
||||
expect(keys).toContain('secret.txt') // gitignore not followed by default → shown
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTreeFromPaths', () => {
|
||||
it('nests paths with dirs before files, alphabetical', () => {
|
||||
const t = buildTreeFromPaths('proj', ['src/b.ts', 'src/a.ts', 'README.md', 'src/util/x.ts'])
|
||||
expect((t.children || []).map((c) => c.name)).toEqual(['src', 'README.md'])
|
||||
const src = (t.children || []).find((c) => c.name === 'src')!
|
||||
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
|
||||
expect((src.children || []).find((c) => c.name === 'util')!.path).toBe('src/util')
|
||||
})
|
||||
|
||||
it('forces dirPaths in as empty folders, deduped against file-derived dirs', () => {
|
||||
const t = buildTreeFromPaths('proj', ['src/a.ts'], ['empty', 'src/sub', 'src'])
|
||||
const top = (t.children || []).map((c) => c.name)
|
||||
expect(top).toEqual(['empty', 'src']) // dirs alphabetical, both present once
|
||||
const src = (t.children || []).find((c) => c.name === 'src')!
|
||||
expect((src.children || []).map((c) => c.name)).toEqual(['sub', 'a.ts'])
|
||||
expect((src.children || []).find((c) => c.name === 'sub')!.type).toBe('dir')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read/write round-trip', () => {
|
||||
it('writes then reads the same content', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'note.txt', 'hello world\n')
|
||||
expect(await readProjectFile(dir, 'note.txt')).toBe('hello world\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProjectFile', () => {
|
||||
it('creates an empty file and makes missing parent folders', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await createProjectFile(dir, 'src/new/fresh.ts')
|
||||
expect(await readProjectFile(dir, 'src/new/fresh.ts')).toBe('')
|
||||
})
|
||||
|
||||
it('refuses to overwrite an existing file', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'keep.txt', 'precious\n')
|
||||
await expect(createProjectFile(dir, 'keep.txt')).rejects.toThrow()
|
||||
expect(await readProjectFile(dir, 'keep.txt')).toBe('precious\n')
|
||||
})
|
||||
|
||||
it('refuses to escape the project root', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await expect(createProjectFile(dir, '../escape.txt')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameProjectEntry', () => {
|
||||
it('renames a file in place and returns the new relative path', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await createProjectFile(dir, 'src/old.ts')
|
||||
await writeProjectFile(dir, 'src/old.ts', 'x\n')
|
||||
const next = await renameProjectEntry(dir, 'src/old.ts', 'new.ts')
|
||||
expect(next).toBe('src/new.ts')
|
||||
expect(await readProjectFile(dir, 'src/new.ts')).toBe('x\n')
|
||||
})
|
||||
|
||||
it('renames a folder with everything inside it', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await createProjectFile(dir, 'old/a.ts')
|
||||
await writeProjectFile(dir, 'old/a.ts', 'a\n')
|
||||
const next = await renameProjectEntry(dir, 'old', 'new')
|
||||
expect(next).toBe('new')
|
||||
expect(await readProjectFile(dir, 'new/a.ts')).toBe('a\n')
|
||||
})
|
||||
|
||||
it('changes only the case of a name', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'notes.md', 'hi\n')
|
||||
expect(await renameProjectEntry(dir, 'notes.md', 'Notes.md')).toBe('Notes.md')
|
||||
expect(await readProjectFile(dir, 'Notes.md')).toBe('hi\n')
|
||||
})
|
||||
|
||||
it('refuses a name that is already taken', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'a.txt', 'a\n')
|
||||
await writeProjectFile(dir, 'b.txt', 'b\n')
|
||||
await expect(renameProjectEntry(dir, 'a.txt', 'b.txt')).rejects.toThrow()
|
||||
expect(await readProjectFile(dir, 'b.txt')).toBe('b\n')
|
||||
})
|
||||
|
||||
it('refuses a path-shaped or empty name', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await createProjectDir(dir, 'sub')
|
||||
await writeProjectFile(dir, 'a.txt', 'a\n')
|
||||
await expect(renameProjectEntry(dir, 'a.txt', 'sub/a.txt')).rejects.toThrow()
|
||||
await expect(renameProjectEntry(dir, 'a.txt', '../a.txt')).rejects.toThrow()
|
||||
await expect(renameProjectEntry(dir, 'a.txt', ' ')).rejects.toThrow()
|
||||
expect(await readProjectFile(dir, 'a.txt')).toBe('a\n')
|
||||
})
|
||||
})
|
||||
25
test/fuzzy.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fuzzy } from '../src/renderer/src/fuzzy'
|
||||
|
||||
describe('fuzzy', () => {
|
||||
it('matches greedily from the left (first c, then a, then t)', () => {
|
||||
expect(fuzzy('cat', 'concatenate')).toEqual([0, 4, 5])
|
||||
})
|
||||
|
||||
it('matches a non-contiguous subsequence', () => {
|
||||
expect(fuzzy('uc', 'UserController')).toEqual([0, 4])
|
||||
})
|
||||
|
||||
it('is case-insensitive (skips the e to reach r)', () => {
|
||||
expect(fuzzy('USR', 'user')).toEqual([0, 1, 3])
|
||||
})
|
||||
|
||||
it('returns null when not a subsequence', () => {
|
||||
expect(fuzzy('xyz', 'abc')).toBeNull()
|
||||
expect(fuzzy('ca', 'abc')).toBeNull() // order matters
|
||||
})
|
||||
|
||||
it('returns an empty index array for an empty query', () => {
|
||||
expect(fuzzy('', 'anything')).toEqual([])
|
||||
})
|
||||
})
|
||||
162
test/git-two-rows.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A file can be staged and then edited again. Git calls that "MM": two rows,
|
||||
// one per group. These tests drive the renderer with such a payload and check
|
||||
// that Diff follows the row you clicked, while Original and Actual do not.
|
||||
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 = 'a\nb\nc\n'
|
||||
const INDEX = 'a\nSTAGED\nc\n'
|
||||
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
|
||||
|
||||
/** Exactly what git-service returns for porcelain "MM". */
|
||||
function stubBridge(): void {
|
||||
installBridge(
|
||||
[
|
||||
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
|
||||
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
|
||||
],
|
||||
{ 'demo.txt': DISK },
|
||||
)
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(stubBridge)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
removeBridge()
|
||||
})
|
||||
|
||||
/** Row text of the read-only view currently on screen. */
|
||||
function viewText(c: HTMLElement): string {
|
||||
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[] {
|
||||
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
|
||||
const head = heads.find((h) => h.textContent?.startsWith(label))!
|
||||
const rows: HTMLElement[] = []
|
||||
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
|
||||
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
|
||||
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => {
|
||||
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
|
||||
})
|
||||
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', () => {
|
||||
it('shows up in both groups', async () => {
|
||||
const c = await boot()
|
||||
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||
})
|
||||
|
||||
it('Diff on the staged row compares HEAD with the staged copy', async () => {
|
||||
const c = await boot()
|
||||
await openSplit(c, 'Staged Changes')
|
||||
expect(splitText(c, 'left')).toEqual(['a', 'b', 'c'])
|
||||
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c'])
|
||||
// The later edit is not part of what is staged, so neither side may show it.
|
||||
expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING')
|
||||
})
|
||||
|
||||
it('Diff on the unstaged row compares the staged copy with disk', async () => {
|
||||
const c = await boot()
|
||||
await openSplit(c, 'Changes')
|
||||
// 'b' was already replaced before staging, so this half must not mention it.
|
||||
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 () => {
|
||||
const c = await boot()
|
||||
await openSplit(c, 'Staged Changes')
|
||||
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged')
|
||||
await openSplit(c, 'Changes')
|
||||
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 () => {
|
||||
const c = await boot()
|
||||
for (const label of ['Staged Changes', 'Changes'] as const) {
|
||||
fireEvent.click(group(c, label)[0])
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
|
||||
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
|
||||
fireEvent.click(original)
|
||||
await waitFor(() => expect(viewText(c)).toContain('b'))
|
||||
expect(viewText(c)).not.toContain('AFTER-STAGING')
|
||||
|
||||
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
|
||||
fireEvent.click(actual)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
|
||||
}
|
||||
})
|
||||
|
||||
it('only the row you opened is highlighted', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
|
||||
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
|
||||
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
|
||||
})
|
||||
})
|
||||
166
test/git.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { classify, discard, load, stage } from '../src/main/git-service'
|
||||
|
||||
describe('classify', () => {
|
||||
it('reads the index code as staged, working code as unstaged', () => {
|
||||
expect(classify('M', ' ')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }])
|
||||
expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }])
|
||||
expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }])
|
||||
expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }])
|
||||
})
|
||||
it('treats untracked as a new (A) unstaged file', () => {
|
||||
expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }])
|
||||
})
|
||||
it('splits a staged-then-edited file into two rows', () => {
|
||||
expect(classify('M', 'M')).toEqual([
|
||||
{ letter: 'M', staged: true },
|
||||
{ letter: 'M', staged: false },
|
||||
])
|
||||
expect(classify('A', 'M')).toEqual([
|
||||
{ letter: 'A', staged: true },
|
||||
{ letter: 'M', staged: false },
|
||||
])
|
||||
expect(classify('M', 'D')).toEqual([
|
||||
{ letter: 'M', staged: true },
|
||||
{ letter: 'D', staged: false },
|
||||
])
|
||||
})
|
||||
it('keeps a merge conflict as one row', () => {
|
||||
expect(classify('U', 'U')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify('A', 'A')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify('D', 'D')).toEqual([{ letter: 'M', staged: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('load (integration against a temp repo)', () => {
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function repo(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-git-'))
|
||||
const g = simpleGit(d)
|
||||
await g.init()
|
||||
await g.addConfig('user.email', 't@example.com')
|
||||
await g.addConfig('user.name', 'Test')
|
||||
await g.addConfig('commit.gpgsign', 'false')
|
||||
await writeFile(join(d, 'a.txt'), '1\n2\n3\n')
|
||||
await g.add('.')
|
||||
await g.commit('init')
|
||||
return d
|
||||
}
|
||||
|
||||
it('returns null for a non-repo directory', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-nogit-'))
|
||||
expect(await load(dir)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a modified file with HEAD-vs-worktree text', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nX\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res!.branch).toBeTruthy()
|
||||
const a = res!.changes.find((c) => c.path === 'a.txt')
|
||||
expect(a?.status).toBe('M')
|
||||
expect(a?.staged).toBe(false)
|
||||
expect(a?.original).toBe('1\n2\n3\n')
|
||||
expect(a?.updated).toBe('1\nX\n3\n')
|
||||
})
|
||||
|
||||
it('reports an untracked file as new (A), original empty', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
const res = await load(dir)
|
||||
const n = res!.changes.find((c) => c.path === 'new.txt')
|
||||
expect(n?.status).toBe('A')
|
||||
expect(n?.staged).toBe(false)
|
||||
expect(n?.original).toBe('')
|
||||
expect(n?.updated).toBe('fresh\n')
|
||||
})
|
||||
|
||||
it('reflects staging', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a staged-then-edited file in both groups, each with its own pair', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n4\n')
|
||||
const res = await load(dir)
|
||||
const rows = res!.changes.filter((c) => c.path === 'a.txt')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
// Staged row: HEAD -> index. It must NOT include the newer edit.
|
||||
const s = rows.find((c) => c.staged)!
|
||||
expect(s.original).toBe('1\n2\n3\n')
|
||||
expect(s.updated).toBe('1\nSTAGED\n3\n')
|
||||
|
||||
// Unstaged row: index -> disk. Only the newer edit.
|
||||
const w = rows.find((c) => !c.staged)!
|
||||
expect(w.original).toBe('1\nSTAGED\n3\n')
|
||||
expect(w.updated).toBe('1\nSTAGED\n3\n4\n')
|
||||
})
|
||||
|
||||
it('gives a staged-new file that was edited again both rows', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'fresh.txt'), 'one\n')
|
||||
await stage(dir, ['fresh.txt'])
|
||||
await writeFile(join(dir, 'fresh.txt'), 'one\ntwo\n')
|
||||
const res = await load(dir)
|
||||
const rows = res!.changes.filter((c) => c.path === 'fresh.txt')
|
||||
expect(rows.map((r) => [r.status, r.staged])).toEqual([['A', true], ['M', false]])
|
||||
expect(rows[0].original).toBe('')
|
||||
expect(rows[0].updated).toBe('one\n')
|
||||
expect(rows[1].original).toBe('one\n')
|
||||
expect(rows[1].updated).toBe('one\ntwo\n')
|
||||
})
|
||||
|
||||
it('keeps one row when a file is only staged', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
const rows = (await load(dir))!.changes.filter((c) => c.path === 'a.txt')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].staged).toBe(true)
|
||||
expect(rows[0].original).toBe('1\n2\n3\n')
|
||||
expect(rows[0].updated).toBe('1\n2\n3\n4\n')
|
||||
})
|
||||
|
||||
it('discard reverts a modified tracked file to HEAD', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')
|
||||
await discard(dir, ['a.txt'])
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('1\n2\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discard removes a new (untracked) file from disk', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
await discard(dir, ['new.txt'])
|
||||
expect(existsSync(join(dir, 'new.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('discard removes a staged-new file', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'staged-new.txt'), 'x\n')
|
||||
await stage(dir, ['staged-new.txt'])
|
||||
await discard(dir, ['staged-new.txt'])
|
||||
expect(existsSync(join(dir, 'staged-new.txt'))).toBe(false)
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'staged-new.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||