Compare commits

...

31 Commits

Author SHA1 Message Date
e126182ae6 improvements
Some checks failed
CI / check (push) Has been cancelled
2026-07-31 13:29:22 +02:00
daf8945da7 improvements 2026-07-29 14:17:56 +02:00
03e16d49a1 handling files when stages and dirty at once 2026-07-28 08:57:36 +02:00
d3bcdb74c2 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-29 09:00:51 +02:00
6c3a021bb9 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-24 13:59:58 +02:00
43131915c0 faster loading
Some checks failed
CI / check (push) Has been cancelled
2026-06-23 08:52:05 +02:00
73bfd2b86a improvements
Some checks failed
CI / check (push) Has been cancelled
2026-06-22 10:18:18 +02:00
ab6f09bde2 several design improvements 2026-06-22 09:27:39 +02:00
7f5d1a0d03 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-22 06:14:29 +02:00
fb4018336f update right click menu
Some checks failed
CI / check (push) Has been cancelled
2026-06-20 21:08:42 +02:00
6114ce440d update lots of stuff
Some checks failed
CI / check (push) Has been cancelled
2026-06-19 10:41:32 +02:00
5e5fc53dde improvements
Some checks failed
CI / check (push) Has been cancelled
2026-06-19 09:59:03 +02:00
0a90ab822f faster
Some checks failed
CI / check (push) Has been cancelled
2026-06-17 19:55:25 +02:00
6beef86506 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-17 17:35:45 +02:00
513af0e164 toggle hidden files
Some checks failed
CI / check (push) Has been cancelled
2026-06-17 15:36:39 +02:00
e55f4e714e search update
Some checks failed
CI / check (push) Has been cancelled
2026-06-17 14:07:19 +02:00
42defcc7cd copy paste
Some checks failed
CI / check (push) Has been cancelled
2026-06-17 13:40:49 +02:00
16296a27da multiple instances
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 13:35:28 +02:00
4b4676a673 fix git monitor
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 12:11:26 +02:00
29c90725ae adds correct git watcher
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 11:42:23 +02:00
7c69853e85 better gitignore
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 10:44:40 +02:00
d5dbf7187d adds the launcher 2026-06-16 09:59:50 +02:00
87fcf9bf93 open in diff or in updated mode
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 09:36:44 +02:00
8fc6478bb1 improvements to the ui 2026-06-16 09:36:18 +02:00
7bac3fe7c5 several nice UI improvements 2026-06-16 08:52:22 +02:00
6a940b9b7a cleanup header titles of col a and col b 2026-06-16 08:38:04 +02:00
0879f51f48 green on the full screen dif 2026-06-16 08:18:10 +02:00
bd466e84a8 resizes the col widths on app resize 2026-06-16 08:11:10 +02:00
299ae17d80 update fixing the build of the app 2026-06-16 07:59:38 +02:00
6abee5f8b6 addds open menu
Some checks failed
CI / check (push) Has been cancelled
2026-06-16 07:41:25 +02:00
66248c4736 update 2026-06-16 06:18:42 +02:00
64 changed files with 11582 additions and 644 deletions

20
.github/workflows/ci.yml vendored Normal file
View 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

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/ node_modules/
out/ out/
dist/ dist/
coverage/
.DS_Store .DS_Store
*.log *.log
*.tsbuildinfo *.tsbuildinfo

2
.helder/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Helder — local, machine-specific state (do not commit).
*

View File

@@ -0,0 +1,27 @@
{
"ai": {
"command": "claude",
"autoLaunch": true
},
"editor": {
"autoSave": false,
"tabSize": 4
},
"git": {
"confirmDiscard": true,
"confirmStage": false,
"confirmUnstage": false,
"defaultDiffMode": "diff",
"refreshInterval": 10000
},
"files": {
"exclude": [],
"followGitignore": false
},
"terminal": {
"shell": null
},
"session": {
"restoreOnLaunch": true
}
}

14
.helder/theme.css Normal file
View 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
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 140,
"tabWidth": 2
}

View File

@@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`). **Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 2 (real integrations) — essentially complete.** All over IPC through the preload bridge (`src/preload/index.ts`): **Phase 2 (real integrations) — complete; DESIGN.md fully implemented.** The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — `⌘P` aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (`npm test`, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (`src/preload/index.ts`):
- **Filesystem** — tree, in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). - **Filesystem** — tree + in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). The tree/index/search share one source of truth: `rg --files` (honors gitignore + `files.exclude`, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable.
- **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`). - **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable. - **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
@@ -45,10 +45,13 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
- **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. - **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. - **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).
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending. - **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
- **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes. - **The 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 agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery. - **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal. - **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts``rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask) ## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask)
@@ -72,6 +75,17 @@ 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. - 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. - `.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 ## 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 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.
@@ -92,5 +106,10 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design
- `npm run build` — type-stripped production build into `out/` (`electron-vite build`). A frontend change is not done until this succeeds. - `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 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 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.
No test/lint runner is wired up yet — add and document them here when introduced. **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.

View File

@@ -4,7 +4,7 @@
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. 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.
> **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). > **Status: working build.** The Electron app is scaffolded and everything above is implemented against the real filesystem, git, terminals, ripgrep search, and the `.helder/` config system. The editor is writable (save · autosave · discard). See [Getting started](#getting-started).
--- ---
@@ -48,19 +48,30 @@ Right-click in the editor to copy a project-relative `path:line` reference (e.g.
## Getting started ## Getting started
> No build tooling exists yet. This section will be filled in once the Electron + Vite toolchain is scaffolded. Requires Node 18+ and a recent `git` on your `PATH`.
Planned implementation order: ```bash
npm install # also rebuilds node-pty for Electron (postinstall)
npm run dev # launch the app with hot reload
```
1. Electron shell + frameless dark window; port design tokens to CSS variables; bundle JetBrains Mono. Helder opens **one project per window** — by default the current working directory. Open a different folder by clicking the project name in the title bar, or launch with `HELDER_PROJECT=/path/to/repo npm run dev`. The agent pane auto-runs the `claude` CLI, so it must be on your `PATH`.
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. ### Scripts
| Command | What it does |
|---------|--------------|
| `npm run dev` | Launch in Electron with HMR |
| `npm run build` | Production build into `out/` |
| `npm start` | Run the built app |
| `npm test` | Run the vitest suite |
| `npm run lint` | ESLint |
| `npm run typecheck` | `tsc --noEmit` (renderer + main/preload) |
| `npm run pack` | Unpacked app into `dist/` (electron-builder) |
| `npm run dist` | Distributable (`.dmg` / `.zip` / etc.) |
| `npm run rebuild` | Re-rebuild `node-pty` for Electron if a terminal shows "PTY unavailable" |
To preview the original design prototype, open `design_handoff_helder_workbench/design/Helder - AI Code Workbench.html` in a browser — a clickable React-via-Babel mock with sample data.
--- ---
@@ -79,17 +90,20 @@ An effective setting is the value from `config.json` if present, otherwise from
## Repository layout ## Repository layout
``` ```
DESIGN.md Functional/UX spec — every panel, state, and interaction src/
CLAUDE.md Guidance for Claude Code working in this repo main/ Electron main process: window + IPC + services
design_handoff_helder_workbench/ (fs-service, git-service, pty-service, search-service, config, project)
README.md Technical handoff — structure, design tokens, integration mechanics preload/ contextIsolation bridge — the only renderer↔OS surface (window.helder)
design/ renderer/ React UI: App, editor (4 diff modes + writable buffer), terminals (xterm),
Helder - AI Code Workbench.html Clickable prototype (open in a browser) overlays (search/menu/toasts), project store, diff/highlight/fuzzy helpers
styles.css Canonical design tokens (the :root block) test/ vitest suite — diff, fuzzy, highlight, config, fs, git
src/*.jsx Prototype components (reference only — replace mock data) electron.vite.config.ts electron-builder.yml eslint.config.js vitest.config.ts
DESIGN.md Functional/UX spec — every panel, state, interaction
CLAUDE.md Guidance + current architecture for Claude Code
design_handoff_helder_workbench/ Original design handoff + clickable prototype
``` ```
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. `DESIGN.md` and `design_handoff_helder_workbench/README.md` remain the design source of truth; the prototype is a visual reference, not shipped.
--- ---

44
build/adhoc-sign.cjs Normal file
View 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' })
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

44
electron-builder.yml Normal file
View 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

View File

@@ -9,7 +9,13 @@ export default defineConfig({
}, },
preload: { preload: {
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
build: { outDir: 'out/preload' }, 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: { renderer: {
root: 'src/renderer', root: 'src/renderer',

46
eslint.config.js Normal file
View 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,
)

Binary file not shown.

5601
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,13 @@
"preview": "electron-vite preview", "preview": "electron-vite preview",
"start": "electron-vite preview", "start": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json", "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", "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" "postinstall": "electron-rebuild -f -w node-pty"
}, },
"dependencies": { "dependencies": {
@@ -20,22 +26,34 @@
"@vscode/ripgrep": "^1.18.0", "@vscode/ripgrep": "^1.18.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"chokidar": "^5.0.0", "chokidar": "^3.6.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"simple-git": "^3.36.0" "simple-git": "^3.36.0"
}, },
"devDependencies": { "devDependencies": {
"@electron/rebuild": "^4.0.4", "@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/prismjs": "^1.26.4",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
"electron": "^31.3.0", "electron": "^31.3.0",
"electron-builder": "^26.15.3",
"electron-vite": "^2.3.0", "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": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.3.5" "typescript-eslint": "^8.61.1",
"vite": "^5.3.5",
"vitest": "^4.1.9"
} }
} }

View File

@@ -10,18 +10,24 @@ import { join } from 'node:path'
* and FONT SIZE live here (as CSS vars), not in the JSON * and FONT SIZE live here (as CSS vars), not in the JSON
* Effective value = config.json over config.default.json, merged key by key. * Effective value = config.json over config.default.json, merged key by key.
*/ */
export type DiffMode = 'original' | 'updated' | 'diff'
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number } editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean }
} }
export const DEFAULTS: HelderConfig = { export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true },
} }
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme. const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
@@ -40,9 +46,47 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
} }
` `
/** 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 current: HelderConfig = DEFAULTS
let themeCss = '' 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> { function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v) return !!v && typeof v === 'object' && !Array.isArray(v)
} }
@@ -77,6 +121,8 @@ export async function resolveConfig(root: string): Promise<void> {
themeCss = THEME_TEMPLATE themeCss = THEME_TEMPLATE
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE) await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
} }
await ensureGitignore(dir)
} catch { } catch {
// Read-only / inaccessible root: fall back to built-in defaults. // Read-only / inaccessible root: fall back to built-in defaults.
current = DEFAULTS current = DEFAULTS

188
src/main/diagnostics.ts Normal file
View File

@@ -0,0 +1,188 @@
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 before we ask for the path or the folder is called "Electron".
app.setName('Helder')
initLogger({ dir: app.getPath('logs'), mirror: isDev })
// 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 })
})
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
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 }

View File

@@ -1,5 +1,6 @@
import { readdir, readFile, stat, writeFile } from 'node:fs/promises' import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path' import { dirname, join, relative, sep } from 'node:path'
import { listFiles, rgAvailable } from './search-service'
export interface FileNode { export interface FileNode {
name: string name: string
@@ -9,7 +10,7 @@ export interface FileNode {
children?: FileNode[] children?: FileNode[]
} }
/** Directories never walked — noise or huge, and not part of "the project". */ /** Directories never walked by the fallback (rg already honors these as globs). */
const IGNORE_DIRS = new Set([ const IGNORE_DIRS = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
@@ -22,10 +23,149 @@ function ignored(name: string): boolean {
return IGNORE_DIRS.has(name) || name === '.DS_Store' return IGNORE_DIRS.has(name) || name === '.DS_Store'
} }
/** Recursive project tree, dirs first then files, alphabetical. */ 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> { export async function readTree(root: string): Promise<FileNode> {
const name = root.split(sep).filter(Boolean).pop() || root const [paths, emptyDirs] = await Promise.all([
return { name, type: 'dir', path: '', open: true, children: await readDir(root, root, 0) } 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[]> { async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
@@ -55,12 +195,6 @@ async function readDir(abs: string, root: string, depth: number): Promise<FileNo
return [...dirs, ...files] return [...dirs, ...files]
} }
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
}
/** Read a single text file (relative path) → string. */ /** Read a single text file (relative path) → string. */
export async function readProjectFile(root: string, rel: string): Promise<string> { export async function readProjectFile(root: string, rel: string): Promise<string> {
const buf = await readFile(join(root, rel)) const buf = await readFile(join(root, rel))
@@ -68,20 +202,105 @@ export async function readProjectFile(root: string, rel: string): Promise<string
return buf.toString('utf8') 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. */ /** 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> { export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
await writeFile(join(root, rel), content, 'utf8') await writeFile(join(root, rel), content, 'utf8')
} }
/** /**
* Build an in-memory content index of all (small, text) files — powers content * Create a new, empty text file (relative path). Creates parent folders as
* search and plain-file viewing without touching disk per keystroke. Capped to * needed, refuses to escape the project root, and throws if the file already
* keep large repos sane. PHASE: swap content search to ripgrep when scaling up. * 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 })
}
/** 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>> { 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> = {} const out: Record<string, string> = {}
let count = 0 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> { async function walk(abs: string): Promise<void> {
if (count >= MAX_INDEXED_FILES) return if (count >= MAX_INDEXED_FILES) return
let entries: import('node:fs').Dirent[] let entries: import('node:fs').Dirent[]
@@ -102,8 +321,7 @@ export async function readAll(root: string): Promise<Record<string, string>> {
if (s.size > MAX_FILE_BYTES) continue if (s.size > MAX_FILE_BYTES) continue
const buf = await readFile(childAbs) const buf = await readFile(childAbs)
if (looksBinary(buf)) continue if (looksBinary(buf)) continue
const rel = relative(root, childAbs).split(sep).join('/') out[relative(root, childAbs).split(sep).join('/')] = buf.toString('utf8')
out[rel] = buf.toString('utf8')
count++ count++
} catch { } catch {
/* skip unreadable */ /* skip unreadable */
@@ -111,7 +329,6 @@ export async function readAll(root: string): Promise<Record<string, string>> {
} }
} }
} }
await walk(root) await walk(root)
return out return out
} }

View File

@@ -1,6 +1,25 @@
import { readFile } from 'node:fs/promises' import { readFile, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { simpleGit, type SimpleGit } from 'simple-git' 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 type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U'
@@ -17,28 +36,131 @@ export interface GitLoad {
changes: GitChange[] changes: GitChange[]
} }
function git(root: string): SimpleGit { interface GitResult {
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 }) code: number
stdout: string
stderr: string
} }
/** Map a porcelain code pair to our display letter + staged flag. */ /**
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } { * Run a git subcommand directly via child_process.
const staged = index !== ' ' && index !== '?' *
const code = staged ? index : working * We deliberately spawn `git` ourselves instead of going through simple-git:
let letter: GitStatusLetter * 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) { switch (code) {
case 'A': case 'C': case '?': letter = 'A'; break case 'A': case 'C': case '?': return 'A'
case 'D': letter = 'D'; break case 'D': return 'D'
case 'R': letter = 'R'; break case 'R': return 'R'
case 'U': letter = 'M'; break case 'U': return 'M'
case 'M': default: letter = 'M'; break default: return 'M'
} }
return { letter, staged }
} }
async function headText(g: SimpleGit, path: string): Promise<string> { /** 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 { try {
return await g.show([`HEAD:${path}`]) 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 { } catch {
return '' return ''
} }
@@ -57,52 +179,149 @@ async function diskText(root: string, path: string): Promise<string> {
export async function isRepo(root: string): Promise<boolean> { export async function isRepo(root: string): Promise<boolean> {
try { try {
return await git(root).checkIsRepo() const { code, stdout } = await runGit(root, ['rev-parse', '--is-inside-work-tree'])
if (code === 0 && stdout.trim() === 'true') return true
} catch { } catch {
return false /* 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'))
} }
export async function load(root: string): Promise<GitLoad | null> { // Coalesce overlapping loads per root: a branch switch or rapid edits fire
const g = git(root) // 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 if (!(await isRepo(root))) return null
const status = await g.status() // A branch switch / checkout from an external tool (Sublime Merge, the CLI)
const branch = status.current || 'HEAD' // 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
const changes: GitChange[] = [] // rejecting (which would leave the git column stale or blank).
for (const f of status.files) { let raw: string | null = null
// simple-git uses path "from -> to" for renames; take the destination. for (let attempt = 0; attempt < 6; attempt++) {
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path try {
const { letter, staged } = classify(f.index, f.working_dir) raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z'])
const isNew = f.index === '?' || f.index === 'A' break
const isDeleted = letter === 'D' } catch (err) {
const original = isNew ? '' : await headText(g, path) if (attempt === 5) throw err
const updated = isDeleted ? '' : await diskText(root, path) await delay(120)
changes.push({ path, status: letter, staged, original, updated }) }
} }
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 } return { branch, changes }
} }
export async function stage(root: string, paths: string[]): Promise<void> { export async function stage(root: string, paths: string[]): Promise<void> {
// `git add` stages modifications, additions AND deletions of the given paths. // `git add` stages modifications, additions AND deletions of the given paths.
await git(root).add(paths) await git(root, ['add', '--', ...paths])
} }
export async function unstage(root: string, paths: string[]): Promise<void> { export async function unstage(root: string, paths: string[]): Promise<void> {
try { const { code } = await runGit(root, ['reset', '--', ...paths])
await git(root).reset(['--', ...paths]) if (code !== 0) {
} catch {
// empty repo (no HEAD yet): fall back to removing from the index. // empty repo (no HEAD yet): fall back to removing from the index.
await git(root).raw(['rm', '--cached', '-r', '--', ...paths]) await git(root, ['rm', '--cached', '-r', '--', ...paths])
} }
} }
export async function commit(root: string, message: string): Promise<void> { export async function commit(root: string, message: string): Promise<void> {
await git(root).commit(message) await git(root, ['commit', '-m', message])
} }
export async function discard(root: string, paths: string[]): Promise<void> { /**
await git(root).checkout(['--', ...paths]) * 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 })
}
}
} }

View File

@@ -1,16 +1,26 @@
import { join, sep } from 'node:path' import { join, resolve, sep } from 'node:path'
import { app, shell, BrowserWindow, ipcMain } from 'electron' 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 { watch, type FSWatcher } from 'chokidar'
import { getName, getRoot, openDialog } from './project' import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service' import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service' import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service' import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getThemeCss, resolveConfig } from './config' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service' import { listFiles, searchContent } from './search-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 isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin' 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([ const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
@@ -18,12 +28,40 @@ const WATCH_IGNORE = new Set([
let watcher: FSWatcher | null = null let watcher: FSWatcher | null = null
let configWatcher: FSWatcher | null = null let configWatcher: FSWatcher | null = null
let gitWatcher: FSWatcher | null = null
let watchTimer: ReturnType<typeof setTimeout> | null = null let watchTimer: ReturnType<typeof setTimeout> | null = null
let gitTimer: ReturnType<typeof setTimeout> | null = null
function broadcast(channel: string): void { function broadcast(channel: string): void {
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel) 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 { function startConfigWatcher(): void {
if (configWatcher) { configWatcher.close(); configWatcher = null } if (configWatcher) { configWatcher.close(); configWatcher = null }
const root = getRoot() const root = getRoot()
@@ -34,6 +72,136 @@ function startConfigWatcher(): void {
configWatcher.on('add', reload).on('change', reload).on('unlink', reload) 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()
syncWindowTitle()
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. */
function buildDockMenu(): Menu {
return Menu.buildFromTemplate([
{ label: 'New Window', click: () => spawnInstance() },
])
}
function startWatcher(): void { function startWatcher(): void {
if (watcher) { watcher.close(); watcher = null } if (watcher) { watcher.close(); watcher = null }
const root = getRoot() const root = getRoot()
@@ -49,41 +217,131 @@ function startWatcher(): void {
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping) watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
} }
function registerIpc(): void { /**
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() })) * ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
ipcMain.handle('project:open', async (e) => { * `catch {}` that showed a generic toast ("Create failed") and dropped the
const win = BrowserWindow.fromWebContents(e.sender) * actual cause, so the log records the channel, its args and the error, then
const next = await openDialog(win) * RETHROWS so the renderer keeps behaving exactly as before.
if (next) { *
await resolveConfig(getRoot()) * Slow calls get a line too: an FS/git handler blocking for seconds is the
startWatcher() * symptom that precedes a beachball, and it's invisible otherwise.
startConfigWatcher() */
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() }
syncWindowTitle()
broadcast('project:changed')
return { root: getRoot(), name: getName() } return { root: getRoot(), name: getName() }
}) })
ipcMain.handle('fs:tree', () => readTree(getRoot())) handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => readAll(getRoot())) handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel)) handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content)) 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) })
// 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) })
ipcMain.handle('git:load', () => load(getRoot())) handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
ipcMain.handle('pty:available', () => ptyAvailable()) handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows)) handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data)) handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows)) handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.on('pty:kill', (_e, id: number) => killPty(id)) 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) })
ipcMain.handle('config:get', () => getConfig()) handle('pty:available', () => ptyAvailable())
ipcMain.handle('config:theme', () => getThemeCss()) 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))
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query)) handle('config:get', () => getConfig())
ipcMain.handle('search:files', () => listFiles(getRoot())) 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) : [] })
// 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 dont save them.',
}
const { response } = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts)
return response === 0 ? 'save' : response === 1 ? 'discard' : 'cancel'
})
}
/** The window title is the open project's folder name (falling back to the app
* name when nothing is open). Push it to every window after a project change. */
function syncWindowTitle(): void {
const title = getName() || 'Helder'
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
} }
function createWindow(): void { function createWindow(): void {
@@ -94,18 +352,33 @@ function createWindow(): void {
minHeight: 680, minHeight: 680,
show: false, show: false,
backgroundColor: '#16171a', backgroundColor: '#16171a',
title: getName() || 'Helder',
titleBarStyle: isMac ? 'hiddenInset' : 'default', titleBarStyle: isMac ? 'hiddenInset' : 'default',
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined, trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
webPreferences: { webPreferences: {
preload: join(__dirname, '../preload/index.js'), preload: join(__dirname, '../preload/index.cjs'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
sandbox: 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()) 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 }) => { win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url) shell.openExternal(url)
return { action: 'deny' } return { action: 'deny' }
@@ -119,21 +392,36 @@ function createWindow(): void {
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
app.setName('Helder') // 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() registerIpc()
await resolveConfig(getRoot()) const initialRoot = getRoot()
startWatcher() logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
startConfigWatcher() if (initialRoot) {
await resolveConfig(initialRoot)
await addRecentProject(initialRoot)
startWatcher()
startConfigWatcher()
startGitWatcher()
}
createWindow() createWindow()
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow() 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', () => { app.on('window-all-closed', () => {
if (watcher) { watcher.close(); watcher = null } if (watcher) { watcher.close(); watcher = null }
if (configWatcher) { configWatcher.close(); configWatcher = null } if (configWatcher) { configWatcher.close(); configWatcher = null }
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
killAllPtys() killAllPtys()
if (!isMac) app.quit() if (!isMac) app.quit()
}) })

169
src/main/logger.ts Normal file
View 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
View 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')
}

View File

@@ -1,36 +1,43 @@
import { basename } from 'node:path' import { basename, join, resolve } from 'node:path'
import { existsSync, statSync } from 'node:fs' import { existsSync, statSync } from 'node:fs'
import { dialog, BrowserWindow } from 'electron' import { readFile, writeFile } from 'node:fs/promises'
import { app, dialog, BrowserWindow } from 'electron'
/** /**
* One project per window. The root is resolved (in order) from $HELDER_PROJECT, * One project per window. The root is resolved (in order) from $HELDER_PROJECT or
* a directory passed on argv, or the process working directory — then it can be * a directory passed on argv; otherwise it starts as `null` — the app then shows
* changed at runtime via the Open Folder dialog. * 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 { function resolveInitialRoot(): string | null {
const envRoot = process.env.HELDER_PROJECT const envRoot = process.env.HELDER_PROJECT
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot if (envRoot && safeIsDir(envRoot)) return resolve(envRoot)
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a)) const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a))
if (argDir) return argDir if (argDir) return resolve(argDir)
return process.cwd() return null
} }
function safeIsDir(p: string): boolean { function safeIsDir(p: string): boolean {
try { return statSync(p).isDirectory() } catch { return false } try { return statSync(p).isDirectory() } catch { return false }
} }
let root = resolveInitialRoot() let root: string | null = resolveInitialRoot()
export function getRoot(): string { export function getRoot(): string | null {
return root return root
} }
export function getName(): string { export function getName(): string {
return root ? basename(root) || root : 'no project' return root ? basename(root) || root : ''
} }
/** Point the window at a project root (absolute) and remember it in recents. */
export function setRoot(next: string): void { export function setRoot(next: string): void {
root = next root = resolve(next)
addRecentProject(root).catch(() => {})
} }
export async function openDialog(win: BrowserWindow | null): Promise<string | null> { export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
@@ -38,8 +45,41 @@ export async function openDialog(win: BrowserWindow | null): Promise<string | nu
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) ? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
: await dialog.showOpenDialog({ properties: ['openDirectory'] }) : await dialog.showOpenDialog({ properties: ['openDirectory'] })
if (!res.canceled && res.filePaths[0]) { if (!res.canceled && res.filePaths[0]) {
root = res.filePaths[0] setRoot(res.filePaths[0])
return root return root
} }
return null 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 */
}
}

View File

@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
import type { WebContents } from 'electron' import type { WebContents } from 'electron'
import { getRoot } from './project' import { getRoot } from './project'
import { getConfig } from './config' import { getConfig } from './config'
import { logger } from './logger'
/** /**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the * Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
@@ -19,12 +20,46 @@ let pty: PtyModule | null = null
try { try {
pty = require('node-pty') as PtyModule pty = require('node-pty') as PtyModule
} catch (e) { } catch (e) {
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message) logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
} }
const terms = new Map<number, import('node-pty').IPty>() 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 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 { function defaultShell(): string {
const configured = getConfig().terminal.shell const configured = getConfig().terminal.shell
if (configured) return configured if (configured) return configured
@@ -37,24 +72,46 @@ export function ptyAvailable(): boolean {
} }
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number { export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1 if (!pty) {
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
return -1
}
const cwd = getRoot() || process.env.HOME || process.cwd() const cwd = getRoot() || process.env.HOME || process.cwd()
const proc = pty.spawn(defaultShell(), [], { const shell = defaultShell()
name: 'xterm-color', 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, cols: cols || 80,
rows: rows || 24, rows: rows || 24,
cwd, cwd,
env: process.env as { [key: string]: string }, env: ptyEnv(),
}) })
const id = ++seq const id = ++seq
terms.set(id, proc) 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.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) }) 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 })
})
const ai = getConfig().ai // Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch) { if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
// small delay so the shell prompt is ready before we type the command
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350) setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
} }
return id return id
@@ -70,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
export function killPty(id: number): void { export function killPty(id: number): void {
const p = terms.get(id) const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) } if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
} }
export function killAllPtys(): void { export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } } for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
terms.clear() terms.clear()
} }

View File

@@ -1,38 +1,66 @@
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { relative, sep } from 'node:path' import { relative, sep } from 'node:path'
import { getConfig } from './config'
/** Content search via ripgrep; file-name list via `rg --files`. Substring /** ripgrep is the single source of "what files are in the project": it powers
* (fixed-string), smart-case — matching the prototype's search semantics. */ * content search, the file-name list, AND the Explorer tree / content index
const require = createRequire(import.meta.url) * (via fs-service) — so gitignore + files.exclude are honored everywhere the
* same way. Substring (fixed-string), smart-case search.
let rgPath: string | null = null *
try { * @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath * ESM and CJS) and cache the resolved binary path. */
} catch (e) { const rgPathPromise: Promise<string | null> = (async () => {
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message) 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 ContentHit { no: number; ln: string; ix: number }
export interface ContentGroup { path: string; hits: ContentHit[] } export interface ContentGroup { path: string; hits: ContentHit[] }
const IGNORE_GLOBS = ['node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder'] /** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */
.flatMap((d) => ['--glob', `!${d}`]) const BASE_IGNORE = [
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
]
const MAX_FILES = 400 const MAX_FILES = 400
const MAX_LINE = 1000 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 { function toRel(root: string, p: string): string {
return relative(root, p).split(sep).join('/') return relative(root, p).split(sep).join('/')
} }
export function searchContent(root: string, query: string): Promise<ContentGroup[]> { 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) => { return new Promise((resolve) => {
if (!rgPath || query.trim().length < 2) return resolve([])
const child = spawn(rgPath, [ const child = spawn(rgPath, [
'--json', '--fixed-strings', '--smart-case', '--json', '--fixed-strings', '--smart-case', '--hidden',
'--max-count', '50', '--max-columns', '2000', '--max-count', '50', '--max-columns', '2000',
...IGNORE_GLOBS, '-e', query, '--', root, ...ignoreArgs(), '-e', query, '--', root,
]) ])
const order: string[] = [] const order: string[] = []
const groups = new Map<string, ContentGroup>() const groups = new Map<string, ContentGroup>()
@@ -65,10 +93,15 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
}) })
} }
export function listFiles(root: string): Promise<string[]> { /** 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) => { return new Promise((resolve) => {
if (!rgPath) return resolve([]) const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
let buf = '' let buf = ''
const out: string[] = [] const out: string[] = []
let done = false let done = false

View File

@@ -10,25 +10,42 @@ const api = {
clipboard: { clipboard: {
writeText: (text: string) => clipboard.writeText(text), writeText: (text: string) => clipboard.writeText(text),
readText: (): string => clipboard.readText(),
}, },
project: { project: {
current: () => ipcRenderer.invoke('project:current'), current: () => ipcRenderer.invoke('project:current'),
open: () => ipcRenderer.invoke('project:open'), open: () => ipcRenderer.invoke('project:open'),
openPath: (path: string) => ipcRenderer.invoke('project:openPath', path),
recent: (): Promise<{ path: string; name: string }[]> => ipcRenderer.invoke('projects:recent'),
}, },
fs: { fs: {
tree: () => ipcRenderer.invoke('fs:tree'), tree: () => ipcRenderer.invoke('fs:tree'),
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
files: () => ipcRenderer.invoke('fs:files'), files: () => ipcRenderer.invoke('fs:files'),
read: (path: string) => ipcRenderer.invoke('fs:read', path), 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), 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),
}, },
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: { git: {
load: () => ipcRenderer.invoke('git:load'), load: () => ipcRenderer.invoke('git:load'),
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths), stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths), unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
commit: (message: string) => ipcRenderer.invoke('git:commit', message), 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), discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
}, },
@@ -56,11 +73,33 @@ const api = {
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'), 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: { search: {
content: (query: string) => ipcRenderer.invoke('search:content', query), content: (query: string) => ipcRenderer.invoke('search:content', query),
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'), files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
}, },
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. */ /** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
onProjectChanged: (cb: () => void): (() => void) => { onProjectChanged: (cb: () => void): (() => void) => {
const handler = (): void => cb() const handler = (): void => cb()
@@ -74,6 +113,20 @@ const api = {
ipcRenderer.on('config:changed', handler) ipcRenderer.on('config:changed', handler)
return () => ipcRenderer.removeListener('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) { if (process.contextIsolated) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
/* Shared icons, FileIcon, GitPanel, FileTree */ /* Shared icons, FileIcon, GitPanel, FileTree */
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import type { Change, FileNode, GitStatus } from './types' import type { Change, DiffSide, FileNode, GitStatus } from './types'
import { HL } from './highlight' import { HL } from './highlight'
type SvgProps = React.SVGProps<SVGSVGElement> type SvgProps = React.SVGProps<SVGSVGElement>
@@ -12,14 +12,23 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
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>), 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>), 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>), 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>), 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>), 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>), 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>), 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>), 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>), 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>), 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>), 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>),
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>),
} }
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
@@ -30,8 +39,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => ( export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none"> <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'} {open ? (
fill={open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'} stroke="currentColor" strokeWidth="1.1" /> <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> </svg>
) )
@@ -41,52 +56,57 @@ export function FileIcon({ path }: { path: string }): React.ReactElement {
} }
/* Shared callback signatures used across panels. */ /* Shared callback signatures used across panels. */
export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number }) => void export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number; side?: DiffSide }) => void
export interface ContextTarget { export interface ContextTarget {
path: string path: string
kind: 'editor' | 'dir' | 'file' | 'git' kind: 'editor' | 'dir' | 'file' | 'git'
staged?: boolean staged?: boolean
sel?: { start: number; end: number } sel?: { start: number; end: number }
line?: number line?: number
code?: string
} }
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */ /* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: { function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
c: Change c: Change
staged: boolean
activePath: string | null activePath: string | null
/** Which half the open tab is showing, so only that row lights up. */
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
onToggleStage: (path: string) => void onToggleStage: (path: string) => void
}): React.ReactElement { }): React.ReactElement {
const staged = c.staged
const side: DiffSide = staged ? 'staged' : 'unstaged'
const name = c.path.split('/').pop() const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/') const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir
const isActive = activePath === c.path && (!activeSide || activeSide === side)
return ( return (
<div className={'git-row' + (activePath === c.path ? ' active' : '')} <div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
onClick={() => onOpen(c.path, { diff: true })} data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
title={c.path}> title={c.path}>
<span className={'git-stat ' + c.status}>{c.status}</span> <span className={'git-stat ' + c.status}>{c.status}</span>
<FileIcon path={c.path} /> <FileIcon path={c.path} />
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span> <span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
{dir && <span className="git-dir">{dir}/</span>} {dirShown && <span className="git-dir">{dir}/</span>}
<button className="git-act" title={staged ? 'Unstage changes' : 'Stage changes'} <button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}> onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
{staged ? Icon.minus() : Icon.plus()} {staged ? Icon.minus() : Icon.plus()}
</button> </button>
<span className="git-delta">
{c.add > 0 && <span className="a">+{c.add}</span>}
{c.del > 0 && <span className="d">-{c.del}</span>}
</span>
</div> </div>
) )
} }
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: { export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: {
branch: string branch: string
changes: Change[] changes: Change[]
staged: Set<string>
committed: Set<string> committed: Set<string>
commitMsg: string commitMsg: string
setCommitMsg: (v: string) => void setCommitMsg: (v: string) => void
@@ -95,32 +115,29 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onStageAll: () => void onStageAll: () => void
onUnstageAll: () => void onUnstageAll: () => void
onCommit: () => void onCommit: () => void
onPush: () => void
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
activeSide: DiffSide | null
ctxPath: string | null
kbdId: string | null
showDir: boolean
}): React.ReactElement { }): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path)) const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => staged.has(c.path)) const stagedList = visible.filter((c) => c.staged)
const changesList = visible.filter((c) => !staged.has(c.path)) const changesList = visible.filter((c) => !c.staged)
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }) const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0 const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
return ( return (
<Fragment> <Fragment>
<div className="phead">
{Icon.branch()}<span>Source Control</span>
<span className="ct">{visible.length}</span>
</div>
<div className="commit-box"> <div className="commit-box">
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false} <textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
placeholder="Message (⌘↵ to commit)" placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(e.target.value)} onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canCommit) { e.preventDefault(); onCommit() } }} /> onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
<button className="commit-btn" disabled={!canCommit} onClick={onCommit} <button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
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>
<div className="git-body"> <div className="git-body">
@@ -133,7 +150,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>} {stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div> </div>
{stagedList.length > 0 ? stagedList.map((c) => ( {stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath} <GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : ( )) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div> <div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -146,7 +163,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>} {changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div> </div>
{changesList.length > 0 ? changesList.map((c) => ( {changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath} <GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : ( )) : (
<div className="git-none">All changes staged</div> <div className="git-none">All changes staged</div>
@@ -167,7 +184,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
} }
/* ============ File Tree ============ */ /* ============ File Tree ============ */
function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: { function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
node: FileNode node: FileNode
depth: number depth: number
openDirs: Set<string> openDirs: Set<string>
@@ -175,8 +192,11 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
ctxPath: string | null
kbdPath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean
}): React.ReactElement { }): React.ReactElement {
const pad = 10 + depth * 13 const pad = 10 + depth * 13
if (node.type === 'dir') { if (node.type === 'dir') {
@@ -184,7 +204,8 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
return ( return (
<Fragment> <Fragment>
{node.path !== '' && ( {node.path !== '' && (
<div className="tree-row folder" style={{ paddingLeft: pad }} <div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => toggleDir(node.path)} onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
<span className="tw"><Chevron open={isOpen} /></span> <span className="tw"><Chevron open={isOpen} /></span>
@@ -192,18 +213,21 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
<span className="tree-label">{node.name}</span> <span className="tree-label">{node.name}</span>
</div> </div>
)} )}
{isOpen && (node.children || []).map((c) => ( {isOpen && (node.children || [])
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1} .filter((c) => showHidden || !c.name.startsWith('.'))
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen} .map((c) => (
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} /> <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> </Fragment>
) )
} }
const status = committed && committed.has(node.path) ? null : changeMap[node.path] const status = committed && committed.has(node.path) ? null : changeMap[node.path]
return ( return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '')} <div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
style={{ paddingLeft: pad + 2 }} style={{ paddingLeft: pad + 2 }}
data-row-path={node.path}
onClick={() => onOpen(node.path)} onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
title={node.path}> title={node.path}>
@@ -215,25 +239,24 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
) )
} }
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: { export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
tree: FileNode tree: FileNode
openDirs: Set<string> openDirs: Set<string>
toggleDir: (path: string) => void toggleDir: (path: string) => void
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
ctxPath: string | null
kbdPath: string | null
changeMap: Record<string, GitStatus> changeMap: Record<string, GitStatus>
committed: Set<string> committed: Set<string>
showHidden: boolean
}): React.ReactElement { }): React.ReactElement {
return ( return (
<Fragment> <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"> <div className="tree-body">
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir} <TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} /> onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
</div> </div>
</Fragment> </Fragment>
) )

View File

@@ -6,6 +6,7 @@
* same original/updated text pair per changed file, so keep buildDiff()'s * same original/updated text pair per changed file, so keep buildDiff()'s
* output shape. */ * output shape. */
import type { Change, Diff, FileNode, Project } from './types' import type { Change, Diff, FileNode, Project } from './types'
import { rowId } from './types'
import { buildDiff } from './diff' import { buildDiff } from './diff'
// ---- working-tree (current / updated) file contents ---------------- // ---- working-tree (current / updated) file contents ----------------
@@ -651,7 +652,8 @@ const changes: Change[] = changeDefs.map((c) => {
original: orig, original: orig,
updated: upd, 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) }
}) })
export const PROJECT: Project = { export const PROJECT: Project = {

View File

@@ -1,14 +1,16 @@
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */ /* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useMemo, useRef } from 'react' import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import type { Diff, ViewLine } from './types' import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project' import { useProject } from './project'
import { HL } from './highlight' import { HL } from './highlight'
import { renderMarkdown } from './markdown'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import type { OnContext } from './components' import type { OnContext } from './components'
export interface Cursor { path: string; line: number; col: number } export interface Cursor { path: string; line: number; col: number }
export interface Selection { path: string; start: number; end: number; anchor: number } export interface Selection { path: string; start: number; end: number; anchor: number }
export type Mode = 'original' | 'updated' | 'diff' | 'code' export type Mode = 'original' | 'updated' | 'diff' | 'code' | 'preview'
function climbToLine(node: Node | null): HTMLElement | null { function climbToLine(node: Node | null): HTMLElement | null {
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null) let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null)
@@ -16,8 +18,6 @@ function climbToLine(node: Node | null): HTMLElement | null {
return el || null return el || null
} }
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a /* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. */ * scroll-synced line-number gutter. Live highlighting while typing. */
function CodeEditor({ path, text, lang, onChange, onContext }: { function CodeEditor({ path, text, lang, onChange, onContext }: {
@@ -29,6 +29,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
}): React.ReactElement { }): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null) const gutterRef = useRef<HTMLDivElement>(null)
const tabSize = useProject().config.editor.tabSize
const html = useMemo(() => HL.hlText(text, lang), [text, lang]) const html = useMemo(() => HL.hlText(text, lang), [text, lang])
const count = useMemo(() => text.split('\n').length, [text]) const count = useMemo(() => text.split('\n').length, [text])
@@ -47,6 +48,48 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
if (top < s.scrollTop) s.scrollTop = top - 20 if (top < s.scrollTop) s.scrollTop = top - 20
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20 else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20
} }
/* 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)
}
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void { function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
e.preventDefault() e.preventDefault()
const ta = e.currentTarget const ta = e.currentTarget
@@ -54,7 +97,8 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine } const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
if (ta.selectionEnd > ta.selectionStart) { if (ta.selectionEnd > ta.selectionStart) {
const endLine = text.slice(0, ta.selectionEnd).split('\n').length const endLine = text.slice(0, ta.selectionEnd).split('\n').length
if (endLine !== startLine) info.sel = { start: startLine, end: endLine } info.sel = { start: startLine, end: endLine }
info.code = ta.value.slice(ta.selectionStart, ta.selectionEnd)
} }
onContext(e, info) onContext(e, info)
} }
@@ -68,48 +112,51 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}> <div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-inner"> <div className="ce-inner">
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off" <textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off" wrap="off" style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }} onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)} onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)} onClick={(e) => ensureCaretVisible(e.currentTarget)}
onContextMenu={handleContext} /> onContextMenu={handleContext} />
<pre className="ce-pre" aria-hidden dangerouslySetInnerHTML={{ __html: html + '\n' }} /> <pre className="ce-pre" aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html + '\n' }} />
</div> </div>
</div> </div>
</div> </div>
) )
} }
function EditorTabs({ tabs, active, onActivate, onClose }: { /* Rendered-markdown preview: read-only, derived from the live buffer text. */
tabs: ResolvedTab[] function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
active: string | null const html = useMemo(() => renderMarkdown(text), [text])
onActivate: (path: string) => void
onClose: (path: string) => void
}): React.ReactElement {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current && ref.current.querySelector('.tab.active')
if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, [active])
return ( return (
<div className="tabs" ref={ref}> <div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
{tabs.map((t) => { <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
const name = t.path.split('/').pop() </div>
return ( )
<div key={t.path} }
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirty' : '')}
onClick={() => onActivate(t.path)} /* Image preview: fetches the file as a data: URL from main (the renderer can't
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }} * read the filesystem) and shows it centred on the editor surface. Read-only. */
title={t.path}> function ImageView({ path, onContext }: { path: string; onContext: OnContext }): React.ReactElement {
<FileIcon path={t.path} /> const [src, setSrc] = useState('')
<span className="tname">{name}</span> const [failed, setFailed] = useState(false)
{t.changed && <span className="tab-mode">{t.modeLabel}</span>} useEffect(() => {
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}> let alive = true
{Icon.close()} setSrc(''); setFailed(false)
</span> const bridge = window.helder
</div> 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)' }}>Cant preview this image</div></div>
: null}
</div> </div>
) )
} }
@@ -168,6 +215,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
if (el) { setCursor({ path, line: +el.dataset.line!, col: caretCol(sel) }); setSelection(null) } 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 { function handleContext(e: React.MouseEvent): void {
e.preventDefault() e.preventDefault()
const sel = window.getSelection() const sel = window.getSelection()
@@ -176,9 +227,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
const f = sel && sel.focusNode && climbToLine(sel.focusNode) const f = sel && sel.focusNode && climbToLine(sel.focusNode)
if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) { 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!) 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.sel = { start: s, end: en }; info.line = s; info.code = codeForRange(s, en)
} else if (selection && selection.path === path && selection.start !== selection.end) { } else if (selection && selection.path === path && selection.start !== selection.end) {
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
info.code = codeForRange(selection.start, selection.end)
} else { } else {
let no: number | null = null let no: number | null = null
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null
@@ -215,7 +267,9 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
) )
} }
/* Build the line descriptors for a given mode. */ /* Build the line descriptors for a given mode. The caller picks which diff to
* pass: Original and Actual always get the file-level HEAD-vs-disk pair, while
* Diff gets the pair of the git row you clicked. */
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } { function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false } if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false } if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
@@ -225,19 +279,23 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false } return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
} }
const SEGMENTS: { id: Mode; label: string }[] = [ /** View options available for a file, given whether it has a diff and whether
{ id: 'original', label: 'Original' }, * it's markdown. Unchanged files only have the plain editable "Code" view; a
{ id: 'updated', label: 'Updated' }, * changed file gets the Updated/Original/Diff trio; markdown adds "Preview". */
{ id: 'diff', label: 'Diff' }, 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({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: { export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
tabs: ResolvedTab[]
active: string | null active: string | null
mode: Mode mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */
side: DiffSide | null
setMode: (m: Mode) => void setMode: (m: Mode) => void
onActivate: (path: string) => void
onClose: (path: string) => void
onContext: OnContext onContext: OnContext
onSplit: (path: string) => void onSplit: (path: string) => void
splitOpen: boolean splitOpen: boolean
@@ -249,64 +307,109 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
onEdit: (text: string) => void onEdit: (text: string) => void
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const tab = tabs.find((t) => t.path === active) 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 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 const diff = tab ? PROJECT.diffs[tab.path] : null
// Diff / Split show 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 lang = tab ? HL.langFor(tab.path) : null
const effMode: Mode = change ? mode : 'code' const hasDiff = !isImage && !!(change && diff)
const isMarkdown = lang === 'markdown'
const segments = segmentsFor(hasDiff, 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'
// The diff actually on screen: the row pair for Diff, the whole file otherwise.
const shown = effMode === 'diff' ? rowDiff : diff
let built: { lines: ViewLine[]; showSign: boolean } | null = null let built: { lines: ViewLine[]; showSign: boolean } | null = null
if (tab) { if (tab && effMode !== 'preview') {
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path]) if (hasDiff) built = buildLines(effMode, shown, PROJECT.files[tab.path])
else built = buildLines('code', null, 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') : '' // 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 = splitOpen ? 'split' : effMode const activeSeg = splitOpen ? 'split' : effMode
const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0 // Keyed off the git status, not the line count: an empty file that still exists
const emptyOriginal = effMode === 'original' && built && built.lines.length === 0 // (a just-created one, or one emptied by hand) has zero lines too, and must get
// Editable in the live-buffer modes; Original/Diff stay read-only review views. // the editor rather than the "deleted" placeholder.
const emptyUpdated = effMode === 'updated' && fileStatus === 'D'
const emptyOriginal = effMode === 'original' && fileStatus === 'A'
// Editable in the live-buffer modes; Original/Diff/Preview stay read-only views.
const editable = effMode === 'code' || effMode === 'updated' const editable = effMode === 'code' || effMode === 'updated'
return ( return (
<Fragment> <Fragment>
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
{!tab ? ( {!tab ? (
<div className="empty-ed"> <div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div> <div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
<div className="big">No file open</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 className="klist">
<div><span>Search files &amp; content</span><kbd> F</kbd></div> <div><span>Open folder</span><kbd>O</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div> <div><span>Recent projects</span><kbd>O</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div> <div><span>Search files &amp; content</span><kbd>F</kbd></div>
<div><span>Project note</span><kbd>N</kbd></div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="editor-wrap"> <div className="editor-wrap">
{change && ( {/* The view bar is always present; what it offers depends on the file
<div className="diff-bar"> state (changed → diff views, markdown → Preview, else just Code). */}
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span> <div className="diff-bar">
{change.add > 0 && <span className="a">+{change.add}</span>} {change ? (
{change.del > 0 && <span className="d">{change.del}</span>} <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"> <div className="seg">
{SEGMENTS.map((s) => ( {segments.map((s) => (
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button> <button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
))} ))}
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side"> {hasDiff && (
<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> <button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
Split <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>
</button> Split
</button>
)}
</div> </div>
</div> )}
)} </div>
{emptyUpdated ? ( {isImage ? (
<ImageView path={tab.path} onContext={onContext} />
) : effMode === '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 style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div> <div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div>
) : emptyOriginal ? ( ) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div> <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>
) : editable ? ( ) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} /> <CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} />
) : ( ) : (
built && <PaneView cacheKey={tab.path + ':' + effMode} path={tab.path} lines={built.lines} built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection} lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} /> setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
)} )}
@@ -317,20 +420,24 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
} }
/* Full-screen side-by-side split view */ /* Full-screen side-by-side split view */
export function SplitView({ path, onClose, onContext }: { export function SplitView({ path, side, onClose, onContext }: {
path: string path: string
/** Which git row opened this file. Split compares that row's pair. */
side: DiffSide | null
onClose: () => void onClose: () => void
onContext: OnContext onContext: OnContext
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const diff = PROJECT.diffs[path] const diff = (side ? PROJECT.rowDiffs[rowId(path, side === 'staged')] : null) || PROJECT.diffs[path]
const lang = HL.langFor(path) const lang = HL.langFor(path)
const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null)
const lock = useRef(false) const lock = useRef(false)
const change = PROJECT.changes.find((c) => c.path === path) 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]) 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]) 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 { function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void {
if (lock.current || !from || !to) return if (lock.current || !from || !to) return
@@ -352,9 +459,10 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-head"> <div className="split-head">
<FileIcon path={path} /> <FileIcon path={path} />
<span className="sh-name">{path}</span> <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 && <span className={'git-stat ' + splitStatus} style={{ width: 'auto' }}>{splitStatus === 'A' ? 'Added' : splitStatus === 'D' ? 'Deleted' : 'Modified'}</span>}
{change && change.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{change.add}</span>} {diff.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{diff.add}</span>}
{change && change.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>{change.del}</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}> <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> <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> Collapse <kbd>Esc</kbd>
@@ -365,7 +473,7 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-label">Original <span>before</span></div> <div className="split-label">Original <span>before</span></div>
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={ctx}> <div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={ctx}>
{diff.split.map((row, i) => ( {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' : '')}> <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-gutter">{row.l ? row.l.no : ''}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : '' }} /> <span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : '' }} />
</div> </div>
@@ -376,7 +484,7 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-label">Updated <span>after</span></div> <div className="split-label">Updated <span>after</span></div>
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}> <div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
{diff.split.map((row, i) => ( {diff.split.map((row, i) => (
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' bar-add' : '') + (!row.r ? ' empty' : '')}> <div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>
<span className="ln-gutter">{row.r ? row.r.no : ''}</span> <span className="ln-gutter">{row.r ? row.r.no : ''}</span>
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : '' }} /> <span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : '' }} />
</div> </div>

View File

@@ -11,22 +11,37 @@ interface GitChangeRaw {
interface HelderBridge { interface HelderBridge {
platform: string platform: string
clipboard: { writeText: (text: string) => void } clipboard: { writeText: (text: string) => void; readText: () => string }
project: { project: {
current: () => Promise<{ root: string | null; name: string }> current: () => Promise<{ root: string | null; name: string }>
open: () => 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: { fs: {
tree: () => Promise<FileNode | null> tree: () => Promise<FileNode | null>
readDir: (path: string) => Promise<FileNode[]>
files: () => Promise<Record<string, string>> files: () => Promise<Record<string, string>>
read: (path: string) => Promise<string> read: (path: string) => Promise<string>
imageDataUrl: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void> write: (path: string, content: string) => Promise<void>
delete: (path: string) => Promise<void>
create: (path: string) => Promise<void>
mkdir: (path: string) => Promise<void>
}
shell: {
reveal: (path: string) => void
}
notes: {
read: () => Promise<string>
write: (text: string) => Promise<void>
} }
git: { git: {
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null> load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
stage: (paths: string[]) => Promise<void> stage: (paths: string[]) => Promise<void>
unstage: (paths: string[]) => Promise<void> unstage: (paths: string[]) => Promise<void>
commit: (message: string) => Promise<void> commit: (message: string) => Promise<void>
push: () => Promise<{ ok: boolean; message: string }>
discard: (paths: string[]) => Promise<void> discard: (paths: string[]) => Promise<void>
} }
pty: { pty: {
@@ -42,12 +57,27 @@ interface HelderBridge {
get: () => Promise<HelderConfig> get: () => Promise<HelderConfig>
theme: () => Promise<string> theme: () => Promise<string>
} }
recent: {
get: () => Promise<string[]>
set: (list: string[]) => Promise<void>
}
search: { search: {
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]> content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
files: () => Promise<string[]> files: () => Promise<string[]>
} }
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 onProjectChanged: (cb: () => void) => () => void
onConfigChanged: (cb: () => void) => () => void onConfigChanged: (cb: () => void) => () => void
onRefresh: (cb: () => void) => () => void
} }
declare global { declare global {

View 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
View 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
}

View File

@@ -34,6 +34,12 @@ function ext(path: string): string {
return i >= 0 ? base.slice(i + 1).toLowerCase() : '' 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 { function langFor(path: string): string | null {
return EXT_LANG[ext(path)] || null return EXT_LANG[ext(path)] || null
} }
@@ -112,4 +118,4 @@ function iconFor(path: string): IconMeta {
return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' } return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' }
} }
export const HL = { ext, langFor, langLabel, hlLine, hlText, iconFor, escapeHtml } export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }

View 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">
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })}
<div className="lp-title"><b>Helder</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><span className="lp-path">Choose a folder</span></div>
<kbd></kbd>
</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"><kbd></kbd> <kbd></kbd> navigate · <kbd></kbd> open</div>
</div>
</div>
)
}

75
src/renderer/src/log.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* 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
/** 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
}
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}`,
})
}

View File

@@ -13,11 +13,18 @@ import './styles.css'
import { App } from './App' import { App } from './App'
import { ProjectProvider } from './project' 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( createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<ProjectProvider> <ErrorBoundary>
<App /> <ProjectProvider>
</ProjectProvider> <App />
</ProjectProvider>
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -0,0 +1,174 @@
/* 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`)
}
/** Inline markdown on one already-untrusted text run. */
function inline(src: 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, url) => {
const u = safeUrl(url)
return u ? `<img alt="${alt}" src="${u}" />` : alt
})
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, t, url) => {
const u = safeUrl(url)
return u ? `<a href="${u}" target="_blank" rel="noreferrer">${t}</a>` : t
})
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): string {
const a = align ? ` style="text-align:${align}"` : ''
return `<${tag}${a}>` + inline(text) + `</${tag}>`
}
export function renderMarkdown(text: string): 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(' ')) + '</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()) + `</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])).join('') + '</tr>').join('')
out.push(
'<table class="md-table"><thead><tr>' +
head.map((c, n) => cell('th', c, aligns[n])).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')) + '</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) + '</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) + '</li>').join('') + '</ol>')
continue
}
para.push(line.trim())
i++
}
flushPara()
return out.join('\n')
}

View File

@@ -1,6 +1,8 @@
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */ /* Overlays: combined search (content + file names), context menu, toast, pass-popup */
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react' import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project' import { useProject } from './project'
import type { RecentProject } from './project'
import { fuzzy } from './fuzzy'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import type { OpenFile } from './components' import type { OpenFile } from './components'
@@ -12,43 +14,65 @@ export interface MenuItem {
kbd?: string kbd?: string
onClick?: () => void onClick?: () => void
} }
export interface Menu { x: number; y: number; note?: string; items: MenuItem[] } export interface Menu { x: number; y: number; note?: string; path?: string; items: MenuItem[] }
export interface Toast { id: number; title: string; ref?: string } 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 ContentHit { no: number; ln: string; ix: number }
interface ContentGroup { path: string; hits: ContentHit[] } interface ContentGroup { path: string; hits: ContentHit[] }
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
}
function Highlight({ text, idx }: { text: string; idx: number[] | null }): React.ReactElement { function Highlight({ text, idx }: { text: string; idx: number[] | null }): React.ReactElement {
if (!idx || !idx.length) return <span>{text}</span> if (!idx || !idx.length) return <span>{text}</span>
const set = new Set(idx) 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> return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
} }
export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: { /** 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 onOpen: OpenFile
onOpenAt: (path: string, line: number) => void onOpenAt: (path: string, line: number) => void
onClose: () => void onClose: () => void
changeSet: Set<string> changeSet: Set<string>
activePath?: string | null
activeText?: string
showHidden?: boolean
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const bridge = window.helder const bridge = window.helder
const [q, setQ] = useState('') const [q, setQ] = useState(() => initialQuery ?? '')
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const [fileSel, setFileSel] = useState(0)
const [inFileSel, setInFileSel] = useState(0)
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)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const inFileRef = useRef<HTMLDivElement>(null)
const leftRef = 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 // file-name list: ripgrep `--files` when available, else the in-memory index keys
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files))) const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
useEffect(() => { useEffect(() => {
inputRef.current && inputRef.current.focus() 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))) if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
}, []) }, [])
@@ -79,12 +103,32 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
return return
}, [q]) }, [q])
// hide dotfile content hits unless the Hidden toggle is on
const visibleContent = useMemo(
() => (showHidden ? content : content.filter((g) => !isHiddenPath(g.path))),
[content, showHidden],
)
// 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) // file-name matches (right)
const files = useMemo(() => { const files = useMemo(() => {
const term = q.trim() const term = q.trim()
if (!term) return [] if (!term) return []
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = [] const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
for (const p of allPaths) { for (const p of allPaths) {
if (!showHidden && isHiddenPath(p)) continue
const name = p.split('/').pop() as string const name = p.split('/').pop() as string
const ni = fuzzy(term, name) const ni = fuzzy(term, name)
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue } if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue }
@@ -93,29 +137,61 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
} }
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length) out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
return out return out
}, [q, allPaths]) }, [q, allPaths, showHidden])
// flat list of content hits for keyboard nav // flat list of content hits for keyboard nav
const flat = useMemo(() => { const flat = useMemo(() => {
const arr: { path: string; no: number }[] = [] const arr: { path: string; no: number }[] = []
content.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no }))) visibleContent.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })))
return arr return arr
}, [content]) }, [visibleContent])
const totalHits = flat.length const totalHits = flat.length
useEffect(() => { setSel(0) }, [q]) const fileCount = Math.min(files.length, 40)
const inFileCount = Math.min(inFile.length, 200)
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q])
useEffect(() => { useEffect(() => {
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel') const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' })
}, [sel]) }, [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 { function onKey(e: React.KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)) } if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() } const i = Math.max(0, cols.indexOf(col))
else if (files[0]) { onOpen(files[0].path); onClose() } 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()
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()
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() } } else if (e.key === 'Escape') { e.preventDefault(); onClose() }
} }
@@ -133,15 +209,35 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
<div className="pi"> <div className="pi">
{Icon.search({ style: { color: 'var(--fg-3)' } })} {Icon.search({ style: { color: 'var(--fg-3)' } })}
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} <input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
placeholder="Search content and file names…" spellCheck={false} /> placeholder="Search this file, the project, and file names…" spellCheck={false} />
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span> <span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
</div> </div>
<div className="search-cols"> <div className="search-cols">
<div className="sc-left" ref={leftRef}> {hasInFile && (
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div> <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>}
<kbd className="col-kbd"></kbd>
</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>} {!hasInFile && <kbd className="col-kbd"></kbd>}</div>
{term.length < 2 && <div className="pempty">Type at least 2 characters</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>} {term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
{content.map((g) => ( {visibleContent.map((g) => (
<Fragment key={g.path}> <Fragment key={g.path}>
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}> <div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
<FileIcon path={g.path} /> <FileIcon path={g.path} />
@@ -152,8 +248,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
flatIx++ flatIx++
const me = flatIx const me = flatIx
return ( return (
<div key={h.no} className={'sr-line' + (me === sel ? ' sel' : '')} <div key={h.no} className={'sr-line' + (me === sel && col === 'content' ? ' sel' : '')}
onMouseEnter={() => setSel(me)} onMouseEnter={() => { setCol('content'); setSel(me) }}
onClick={() => { onOpenAt(g.path, h.no); onClose() }}> onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
<span className="no">{h.no}</span> <span className="no">{h.no}</span>
{renderLine(h.ln, h.ix, term.length)} {renderLine(h.ln, h.ix, term.length)}
@@ -163,15 +259,17 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
</Fragment> </Fragment>
))} ))}
</div> </div>
<div className="sc-right"> <div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div> <div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>} <kbd className="col-kbd"></kbd></div>
{!term && <div className="pempty sm">Start typing</div>} {!term && <div className="pempty sm">Start typing</div>}
{term && files.length === 0 && <div className="pempty sm">No file names match</div>} {term && files.length === 0 && <div className="pempty sm">No file names match</div>}
{files.slice(0, 40).map((r) => { {files.slice(0, 40).map((r, i) => {
const name = r.path.split('/').pop() as string const name = r.path.split('/').pop() as string
const dir = r.path.split('/').slice(0, -1).join('/') const dir = r.path.split('/').slice(0, -1).join('/')
return ( return (
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose() }} title={r.path}> <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} /> <FileIcon path={r.path} />
<div className="fres-txt"> <div className="fres-txt">
<span className="fn"><Highlight text={name} idx={r.idx} /></span> <span className="fn"><Highlight text={name} idx={r.idx} /></span>
@@ -188,24 +286,296 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
) )
} }
/* 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)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.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 = 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" ref={listRef}>
{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}
onMouseEnter={() => setSel(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: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
{ keys: ['⌘', '↑'], label: 'Navigate a list up' },
{ keys: ['⌘', '↓'], label: 'Navigate a list down' },
{ keys: ['↵'], label: 'Open the selected list item' },
{ keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['↑', '↓'], label: 'Git/Explorer: move the row cursor (panel must be focused)' },
{ keys: ['↵'], label: 'Git/Explorer: open the selected row' },
{ keys: ['⌘', '→'], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' },
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' },
]
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({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Keyboard shortcuts</span>
<kbd>esc</kbd>
</div>
<div className="help-list">
{SHORTCUTS.map((s, i) => (
<div key={i} className="help-row">
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
<span className="help-label">{s.label}</span>
</div>
))}
</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({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Note</span>
<span className="notes-file">.notes.txt</span>
<span className="notes-hint">{Icon.spark()} To agent <kbd>P</kbd></span>
<kbd>esc</kbd>
</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>
</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 { export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(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(() => { useEffect(() => {
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() } 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') 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('mousedown', h)
document.addEventListener('keydown', k) document.addEventListener('keydown', k, true)
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k) } return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [items.length])
if (!menu) return null if (!menu) return null
const x = Math.min(menu.x, window.innerWidth - 270) const x = Math.min(menu.x, window.innerWidth - 270)
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60)) const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60))
return ( return (
<div className="ctx" ref={ref} style={{ left: x, top: y }}> <div className="ctx-menu" ref={ref} style={{ left: x, top: y }}>
{menu.note && <div className="ctx-note">{menu.note}</div>} {menu.note && <div className="ctx-note">{menu.note}</div>}
{menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : ( {menu.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '')} <div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
onClick={() => { it.onClick && it.onClick(); onClose() }}> onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span> <span className="ic">{it.icon}</span>
<span>{it.label}</span> <span>{it.label}</span>
{it.kbd && <span className="kc">{it.kbd}</span>} {it.kbd && <span className="kc">{it.kbd}</span>}
@@ -229,17 +599,18 @@ export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
) )
} }
export function PassPopup({ x, y, refStr, onConfirm, onCancel }: { export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
x: number x: number
y: number y: number
refStr: string refStr: string
onConfirm: (text: string) => void code?: string
onConfirm: (payload: string) => void
onCancel: () => void onCancel: () => void
}): React.ReactElement { }): React.ReactElement {
const [text, setText] = useState('') const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null) const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => { inputRef.current && inputRef.current.focus() }, []) useEffect(() => { if (inputRef.current) inputRef.current.focus() }, [])
useEffect(() => { useEffect(() => {
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() } 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() } } const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); onCancel() } }
@@ -249,7 +620,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
}, []) }, [])
const left = Math.min(x, window.innerWidth - 360) const left = Math.min(x, window.innerWidth - 360)
const top = Math.min(y + 6, window.innerHeight - 150) const top = Math.min(y + 6, window.innerHeight - 150)
const preview = (text.trim() ? text.trim() + ' ' : '') + refStr const payload = buildPass(text, refStr, code)
return ( return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}> <div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div> <div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
@@ -257,11 +628,51 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
placeholder="Add a note (optional)…" placeholder="Add a note (optional)…"
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); onConfirm(text) } if (e.key === 'Enter') { e.preventDefault(); onConfirm(payload) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() } else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} /> }} />
<div className="pass-preview"><span className="pp-lbl">inserts</span><code>{preview}</code></div> <div className="pass-preview"><span className="pp-lbl">inserts</span><code className={code != null ? 'pp-code multiline' : 'pp-code'}>{payload}</code></div>
<div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div> <div className="pass-foot"><kbd></kbd> insert into agent · <kbd>esc</kbd> cancel</div>
</div> </div>
) )
} }
export function NamePopup({ x, y, dir, kind = 'file', onConfirm, onCancel }: {
x: number
y: number
dir: string
kind?: 'file' | 'folder'
onConfirm: (name: string) => void
onCancel: () => void
}): React.ReactElement {
const isFolder = kind === 'folder'
const [name, setName] = 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 trimmed = name.trim()
const target = (dir ? dir + '/' : '') + trimmed
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{isFolder ? Icon.folder() : Icon.file()}<span>{isFolder ? 'New folder' : 'New file'}</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 (trimmed) onConfirm(trimmed) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">creates</span><code>{target ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> create {isFolder ? 'folder' : 'file'} · <kbd>esc</kbd> cancel</div>
</div>
)
}

View 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 */
}
}

View File

@@ -3,23 +3,42 @@
* already consumed from the mock. When window.helder is absent (e.g. a plain * 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. */ * browser preview) it falls back to the mock so the UI still renders. */
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { Change, Diff, FileNode, HelderConfig } from './types' import type { Change, Diff, FileNode, GitStatus, HelderConfig } from './types'
import { DEFAULT_CONFIG } from './types' import { DEFAULT_CONFIG, rowId } from './types'
import { makeDiff } from './diff' import { makeDiff } from './diff'
import { rlog } from './log'
import { PROJECT as MOCK } from './data' import { PROJECT as MOCK } from './data'
export interface RecentProject { path: string; name: string }
export interface ProjectData { export interface ProjectData {
name: string name: string
root: string | null root: string | null
branch: string branch: string
tree: FileNode | null tree: FileNode | null
files: Record<string, string> files: Record<string, string>
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
changes: Change[] changes: Change[]
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
diffs: Record<string, Diff> diffs: Record<string, Diff>
/** Per row id: that row's own pair. Drives Diff and Split. */
rowDiffs: Record<string, Diff>
/** Paths that have a staged row. */
staged: Set<string> staged: Set<string>
config: HelderConfig config: HelderConfig
isRepo: boolean isRepo: boolean
ready: 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. */ /** Inject the project's theme.css over the built-in dark theme. */
@@ -35,29 +54,60 @@ function applyTheme(css: string): void {
export interface ProjectActions { export interface ProjectActions {
openFolder: () => void openFolder: () => void
openProjectPath: (path: string) => void
refresh: () => 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 stage: (path: string) => void
unstage: (path: string) => void unstage: (path: string) => void
stageAll: () => void stageAll: () => void
unstageAll: () => void unstageAll: () => void
commit: (message: string) => Promise<number> commit: (message: string) => Promise<number>
push: () => Promise<{ ok: boolean; message: string }>
discard: (path: string) => void discard: (path: string) => void
ensureFile: (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'] 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 { function mockData(): ProjectData {
const staged = new Set(MOCK_STAGED)
const changes = mockChanges(staged)
return { return {
name: MOCK.name, root: null, branch: MOCK.branch, // non-null root so browser-preview shows the workbench, not the launcher
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs, name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, 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 = { const emptyData: ProjectData = {
name: 'Loading…', root: null, branch: '—', tree: null, files: {}, name: 'Loading…', root: null, branch: '—', tree: null, files: {},
changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
} }
const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
@@ -65,6 +115,37 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
actions: {} as ProjectActions, 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 { export function useProject(): ProjectData {
return useContext(Ctx).data return useContext(Ctx).data
} }
@@ -77,33 +158,69 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
const [data, setData] = useState<ProjectData>(emptyData) const [data, setData] = useState<ProjectData>(emptyData)
const dataRef = useRef(data) const dataRef = useRef(data)
dataRef.current = 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> { async function loadReal(): Promise<void> {
if (!bridge) return if (!bridge) return
const [cur, tree, files, git, config, theme] = await Promise.all([ // A branch switch fires several watcher pings in quick succession, so
bridge.project.current(), // 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.fs.tree(),
bridge.fs.files(),
bridge.git.load(), bridge.git.load(),
bridge.config.get(), bridge.config.get(),
bridge.config.theme(), bridge.config.theme(),
]) ])
if (seq !== loadSeq.current) return
applyTheme(theme) applyTheme(theme)
const changes: Change[] = [] // The git slice is only applied if no newer git-only read has started since;
const diffs: Record<string, Diff> = {} // otherwise keep the fresher git state and update everything else.
const staged = new Set<string>() const gitFresh = gseq === gitSeq.current
if (git) { setData((d) => ({
for (const c of git.changes) { name: cur.name, root: cur.root,
const d = makeDiff(c.status, c.original, c.updated) tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
diffs[c.path] = d ...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }) }))
if (c.staged) staged.add(c.path) // 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
setData({ // it. Lazily-loaded files (ensureFile) win over the bulk read.
name: cur.name, root: cur.root, branch: git ? git.branch : '—', bridge.fs.files().then((files) => {
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true, 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> { async function loadConfigTheme(): Promise<void> {
@@ -115,6 +232,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
useEffect(() => { useEffect(() => {
if (!bridge) { setData(mockData()); return } 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 }))) loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) }) const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) }) const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
@@ -126,38 +248,55 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
if (!bridge) { if (!bridge) {
// ---- mock-mode actions (preview only) ---- // ---- mock-mode actions (preview only) ----
const setStaged = (fn: (s: Set<string>) => Set<string>): void => const setStaged = (fn: (s: Set<string>) => Set<string>): void =>
setData((d) => ({ ...d, staged: fn(new Set(d.staged)) })) 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 { return {
openFolder: () => {}, openFolder: () => {},
openProjectPath: () => {},
refresh: () => setData(mockData()), refresh: () => setData(mockData()),
refreshDir: () => {},
refreshGit: () => {},
stage: (p) => setStaged((s) => (s.add(p), s)), stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)), unstage: (p) => setStaged((s) => (s.delete(p), s)),
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })), stageAll: () => setStaged(() => new Set(dataRef.current.changes.map((c) => c.path))),
unstageAll: () => setData((d) => ({ ...d, staged: new Set() })), unstageAll: () => setStaged(() => new Set()),
commit: async (_msg) => { commit: async (_msg) => {
const cur = dataRef.current const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() })) setData((d) => ({ ...d, changes: d.changes.filter((c) => !c.staged), staged: new Set() }))
return n return n
}, },
push: async () => ({ ok: true, message: 'Pushed (preview)' }),
discard: (p) => setData((d) => ({ discard: (p) => setData((d) => ({
...d, ...d,
changes: d.changes.filter((c) => c.path !== p), changes: d.changes.filter((c) => c.path !== p),
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(), staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
})), })),
ensureFile: () => {}, ensureFile: () => {},
reloadFile: async (p) => dataRef.current.files[p] ?? '',
} }
} }
// ---- real git-backed actions ---- // ---- real git-backed actions ----
const after = (op: Promise<unknown>): void => { op.then(() => loadReal()).catch(() => {}) } const after = (op: Promise<unknown>): void => { op.then(() => loadGit()).catch(() => {}) }
return { return {
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) }, openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
refresh: () => { 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])), stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])), unstage: (p) => after(bridge.git.unstage([p])),
stageAll: () => { stageAll: () => {
const cur = dataRef.current // Every path with an unstaged row — including files that already have a
const unstaged = cur.changes.filter((c) => !cur.staged.has(c.path)).map((c) => c.path) // 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)) if (unstaged.length) after(bridge.git.stage(unstaged))
}, },
unstageAll: () => { unstageAll: () => {
@@ -166,17 +305,34 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
}, },
commit: async (msg) => { commit: async (msg) => {
const cur = dataRef.current const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
await bridge.git.commit(msg) await bridge.git.commit(msg)
await loadReal() await loadGit()
return n return n
}, },
push: async () => {
const r = await bridge.git.push()
await loadGit()
return r
},
discard: (p) => after(bridge.git.discard([p])), discard: (p) => after(bridge.git.discard([p])),
ensureFile: (path) => { ensureFile: (path) => {
if (dataRef.current.files[path] != null) return if (dataRef.current.files[path] != null) return
bridge.fs.read(path).then((txt) => { bridge.fs.read(path).then((txt) => {
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } })) setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
}).catch(() => {}) }).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 // eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -1,21 +1,24 @@
/* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */ /* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */
:root { :root {
--bg-0:#16171a; /* editor surface (deepest) */ --bg-0:#2b2e34; /* editor surface (deepest) */
--bg-1:#1a1c1f; /* terminals */ --bg-1:#30343b; /* terminals */
--bg-2:#1f2226; /* sidebars */ --bg-2:#373c44; /* sidebars */
--bg-3:#23262b; /* headers / tabs strip */ --bg-3:#40454e; /* headers / tabs strip */
--hover:#2a2e34; --hover:#4a505a;
--active:#313742; --active:#535a66;
--sel:#2b323d; --sel:#49525f;
--border:#2a2d33; --border:#474c55;
--border-2:#34383f; --border-2:#535963;
--fg-0:#e6e8ea; --fg-0:#fbfcfd;
--fg-1:#b4bac2; --fg-1:#dde1e7;
--fg-2:#838a94; --fg-2:#b0b6bf;
--fg-3:#5d636c; --fg-3:#8f96a0;
--accent:#4d8dff; --accent:#f19f3f;
--accent-soft:rgba(77,141,255,0.16);
--accent-line:rgba(77,141,255,0.55);
--accent-soft:rgba(241,159,63,0.16);
--accent-line:rgba(241,159,63,0.55);
--add:#5cbd6b; --add:#5cbd6b;
--del:#e0696a; --del:#e0696a;
--mod:#d8a85c; --mod:#d8a85c;
@@ -50,7 +53,13 @@ body {
overflow:hidden; -webkit-font-smoothing:antialiased; overflow:hidden; -webkit-font-smoothing:antialiased;
} }
#root { height:100vh; } #root { height:100vh; }
::selection { background:rgba(77,141,255,0.32); } ::selection { background:rgba(241,159,63,0.30); }
/* 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:11.5px; color:var(--fg-3);
background:transparent; border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
/* scrollbars */ /* scrollbars */
::-webkit-scrollbar { width:11px; height:11px; } ::-webkit-scrollbar { width:11px; height:11px; }
@@ -62,31 +71,81 @@ body {
.app { display:flex; flex-direction:column; height:100vh; } .app { display:flex; flex-direction:column; height:100vh; }
.titlebar { .titlebar {
height:36px; flex:0 0 36px; display:flex; align-items:center; height:36px; flex:0 0 36px; display:flex; align-items:center; position:relative;
background:var(--bg-3); border-bottom:1px solid var(--border); background:var(--bg-3); border-bottom:1px solid var(--border);
padding:0 12px; gap:14px; user-select:none; padding:0 12px; gap:14px; 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 */
.focus-flash {
position:fixed; inset:0; z-index:200; pointer-events:none;
display:flex; align-items:center; justify-content:center;
animation:ff-fade 2s ease forwards;
}
.ff-card {
display:flex; align-items:center; gap:9px;
font-family:var(--mono); font-size:24px; line-height:1;
padding:20px 34px; border-radius:14px;
background:rgba(22,23,26,0.82); border:1px solid var(--border-2);
backdrop-filter:blur(8px); box-shadow:0 18px 60px rgba(0,0,0,0.5);
}
.ff-card svg { color:var(--accent); flex:0 0 auto; }
@keyframes ff-fade { 0%{opacity:0;} 12%{opacity:1;} 72%{opacity:1;} 100%{opacity:0;} }
@media (prefers-reduced-motion:reduce) { .focus-flash { animation:ff-fade-rm 2s steps(1) forwards; } @keyframes ff-fade-rm { 0%{opacity:1;} 99%{opacity:1;} 100%{opacity:0;} } }
.traffic { display:flex; gap:8px; } .traffic { display:flex; gap:8px; }
.traffic i { width:12px; height:12px; border-radius:50%; display:block; } .traffic i { width:12px; height:12px; border-radius:50%; display:block; }
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;} .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 { 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-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 { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); display:flex; align-items:center; gap:2px; }
.tb-crumb .seg{color:var(--fg-2);} .tb-crumb .seg{color:var(--fg-2);}
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
.tb-spacer { flex:1; } .tb-spacer { flex:1; }
.tb-actions { display:flex; gap:6px; align-items:center; } .tb-actions { display:flex; gap:6px; align-items:center; }
/* Title-bar actions are borderless — the accent alone says "on", so no On/Off
badge is needed. Hover is the only other surface they get. */
.tb-btn { .tb-btn {
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent; font-size:11.5px; color:var(--fg-2); background:transparent; border:0;
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px; 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: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; } .tb-toggle.on, .tb-toggle.on:hover { color:var(--accent); }
.tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
.tb-toggle.on:hover { background:var(--accent-soft); }
.workbench { flex:1; display:flex; min-height:0; } .workbench { flex:1; display:flex; min-height:0; }
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); } /* --col-bg holds each column's resting background so the ⌘R flash below can
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; } animate back to it, whichever state the column is in. */
.col.right-col { background:var(--bg-1); } .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-1); background:var(--col-bg); }
/* Active panel: subtle lighter-gray tint on the focused column. C uses the same
tint as the side panels, so the file view stays in step with B in/out of focus. */
.col.panel-active { --col-bg:#22252a; background:var(--col-bg); }
.col.right-col.panel-active { --col-bg:#1e2024; 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:#3a4048; } 100% { background:var(--col-bg); } }
@keyframes col-refresh-b { 0% { background:#3a4048; } 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); }
}
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; } .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::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
@@ -109,8 +168,10 @@ body {
.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 { 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:focus { border-color:var(--accent-line); }
.commit-input::placeholder { color:var(--fg-3); } .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; } .push-btn { flex:0 0 auto; display:flex; align-items:center; justify-content:center; width:32px; min-height:32px; align-self:stretch; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-2); cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#5d97ff; } .push-btn:hover { background:var(--hover); border-color:var(--accent-line); 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; } .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-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 { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
@@ -126,20 +187,22 @@ body {
.git-row { .git-row {
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative; display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
} }
.git-row:hover { background:var(--hover); } .git-row:hover, .git-row.ctx { background:var(--hover); }
.git-row.ctx .git-act { visibility:visible; }
.git-row.active { background:var(--sel); } .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-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.git-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
.git-row.kbd .git-act { visibility:visible; }
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; } .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-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-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-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); } .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-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-act { flex:0 0 auto; display:flex; visibility:hidden; 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.push { margin-left:auto; } /* right-align when the dir column is hidden */
.git-row:hover .git-act { visibility:visible; }
.git-act:hover { background:var(--active); color:var(--fg-0); } .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); } .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 { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
.branch-chip b { font-weight:600; color:var(--fg-0); } .branch-chip b { font-weight:600; color:var(--fg-0); }
@@ -147,9 +210,10 @@ body {
/* ============ file tree ============ */ /* ============ file tree ============ */
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; } .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 { 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:hover, .tree-row.ctx { background:var(--hover); }
.tree-row.active { background:var(--sel); } .tree-row.active { background:var(--sel); }
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); } .tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
.tree-row.kbd { background:var(--hover); box-shadow:inset 2px 0 0 var(--accent-line); }
.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; } .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-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.active .tree-label { color:var(--fg-0); }
@@ -164,29 +228,19 @@ body {
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); } .folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
/* ============ editor ============ */ /* ============ 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-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; } /* 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); line-height:20px; padding:6px 0 40px;
display:grid; grid-template-columns:minmax(max-content, 1fr); align-content:start; }
.ln-row { display:flex; align-items:flex-start; min-height:20px; } .ln-row { display:flex; align-items:flex-start; min-height:20px; }
.ln-row.cursor { background:rgba(255,255,255,0.035); } .ln-row.cursor { background:rgba(255,255,255,0.035); }
.ln-row.add { background:var(--add-bg); } .ln-row.add { background:var(--add-bg); }
@@ -198,11 +252,13 @@ body {
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); } .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.add .ln-sign { color:var(--add); }
.ln-row.del .ln-sign { color:var(--del); } .ln-row.del .ln-sign { color:var(--del); }
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; } /* 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 6px; min-width:0; }
.editor.diff .ln-code { padding-left:6px; } .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 { 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 .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 { 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; } .empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
@@ -219,6 +275,51 @@ body {
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); } .diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); } .diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
.diff-bar .seg .split-btn svg { opacity:.85; } .diff-bar .seg .split-btn svg { opacity:.85; }
.diff-bar .db-lang { font-family:var(--mono); font-size:10.5px; color:var(--fg-3); letter-spacing:.02em; }
/* 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:10px; color:var(--fg-3); letter-spacing:.02em;
padding:1px 5px; border:1px solid var(--border); border-radius:4px; 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:6px;
/* Checkerboard so transparent PNGs/SVGs read clearly on the dark surface. */
background-color:#2a2d33;
background-image:
linear-gradient(45deg, #232529 25%, transparent 25%), linear-gradient(-45deg, #232529 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #232529 75%), linear-gradient(-45deg, transparent 75%, #232529 75%);
background-size:20px 20px;
background-position:0 0, 0 10px, 10px -10px, -10px 0;
box-shadow:0 4px 24px rgba(0,0,0,.4);
}
/* rendered-markdown preview (Preview view option) */
.md-view { flex:1; overflow:auto; padding:8px 0 48px; }
.md-body { max-width:860px; margin:0 auto; padding:14px 40px 40px; color:var(--fg-1); font-family:var(--ui); font-size:14px; line-height:1.65; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 { color:var(--fg-0); font-weight:600; line-height:1.3; margin:1.4em 0 .55em; }
.md-body h1 { font-size:1.7em; padding-bottom:.3em; border-bottom:1px solid var(--border); }
.md-body h2 { font-size:1.4em; padding-bottom:.25em; border-bottom:1px solid var(--border); }
.md-body h3 { font-size:1.18em; } .md-body h4 { font-size:1.02em; }
.md-body h1:first-child, .md-body h2:first-child, .md-body h3:first-child { margin-top:.2em; }
.md-body p { margin:.7em 0; }
.md-body a { color:var(--accent); text-decoration:none; } .md-body a:hover { text-decoration:underline; }
.md-body ul, .md-body ol { margin:.6em 0; padding-left:1.6em; } .md-body li { margin:.25em 0; }
.md-body blockquote { margin:.8em 0; padding:.1em 1em; border-left:3px solid var(--border-2); color:var(--fg-2); }
.md-body hr { border:0; border-top:1px solid var(--border); margin:1.4em 0; }
.md-body img { max-width:100%; border-radius:6px; }
.md-body code { font-family:var(--code-font); font-size:.88em; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:.1em .35em; }
.md-body pre.md-code { background:var(--bg-1); border:1px solid var(--border); border-radius:8px; padding:12px 14px; overflow:auto; margin:.9em 0; }
.md-body pre.md-code code { font-size:var(--code-size); 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:.9em 0; font-size:.94em; }
.md-body table.md-table th, .md-body table.md-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; vertical-align:top; }
.md-body table.md-table th { background:var(--bg-2); color:var(--fg-0); font-weight:600; white-space:nowrap; }
.md-body table.md-table tbody tr:nth-child(even) { background:var(--bg-1); }
/* gutter change bars (Original / Updated / Split) */ /* gutter change bars (Original / Updated / Split) */
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); } .ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
@@ -232,32 +333,32 @@ body {
.split-head .git-stat { font-size:11px; } .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 { 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: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-body { flex:1; display:flex; min-height:0; }
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; } .split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
.split-pane.left { border-right:1px solid var(--border-2); } .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 { 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; } .split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
/* syntax token colors */ /* syntax token colors — applied to both the read-only line views (.ln-code)
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);} * and the editable buffer's highlight layer (.ce-pre) */
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);} .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.number,.ln-code .token.unit{color:var(--t-num);} .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.function,.ln-code .token.method{color:var(--t-fn);} .ln-code .token.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);}
.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.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);}
.ln-code .token.tag{color:var(--t-tag);} .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);font-style:italic;}
.ln-code .token.attr-name{color:var(--t-attr);} .ln-code .token.tag,.ce-pre .token.tag{color:var(--t-tag);}
.ln-code .token.punctuation{color:var(--t-punc);} .ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);}
.ln-code .token.operator{color:var(--t-punc);} .ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);}
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);} .ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);}
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);} .ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);}
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);} .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.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);} .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.parameter{color:var(--fg-0);} .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.namespace{color:var(--fg-2);} .ln-code .token.parameter,.ce-pre .token.parameter{color:var(--fg-0);}
.ln-code .token.selector{color:var(--t-tag);} .ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);}
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);} .ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);}
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);} .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) ============ */ /* ============ terminals (right column) ============ */
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); } .term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
@@ -289,9 +390,11 @@ body {
/* ============ overlays ============ */ /* ============ 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); } .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 { 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, .search-modal .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 input, .search-modal .pi input { flex:1; min-width:0; 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 .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: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; } .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 { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
.pres.sel { background:var(--accent-soft); } .pres.sel { background:var(--accent-soft); }
@@ -301,12 +404,16 @@ body {
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; } .pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
/* combined search modal (content + files) */ /* 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-modal { width:min(1680px, 92vw); 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; display:flex; flex-direction:column; }
.search-cols { display:flex; min-height:0; } .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-infile { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; background:rgba(0,0,0,0.18); }
.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-left { flex:0 0 60%; min-width:0; max-height:min(72vh, 720px); overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
.sc-right { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); 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-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-right .sc-head { background:#1e2024; }
.sc-infile .sc-head { background:#1c1e22; text-transform:none; letter-spacing:0; }
.sc-infile .sc-head .scf-name { flex:1 1 auto; min-width:0; font-size:11.5px; color:var(--fg-1); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.sc-infile .sc-head svg { flex:0 0 auto; }
.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; } .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; } .srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; } .pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
@@ -317,8 +424,23 @@ body {
.fres-txt .fn b { color:var(--accent); font-weight:700; } .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; } .fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
.history-modal { width:560px; 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; display:flex; flex-direction:column; }
.history-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.history-modal .pi svg { flex:0 0 auto; }
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
.hist-row:hover { background:var(--hover); }
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* content search */ /* content search */
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; } .search-results { max-height:min(68vh, 680px); 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 { 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:hover { color:var(--fg-0); }
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } .sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
@@ -341,8 +463,8 @@ body {
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; } .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 .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-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-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); } .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 */ /* terminal multi-line input */
.term-input { align-items:flex-start; } .term-input { align-items:flex-start; }
@@ -350,9 +472,13 @@ body {
.term-ta::placeholder { color:var(--fg-3); } .term-ta::placeholder { color:var(--fg-3); }
/* context menu */ /* 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); } /* 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. */
.ctx-menu { 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 { 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:hover, .ctx-item.hi { 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 .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 { color:var(--fg-0); }
.ctx-item.primary .ic { color:var(--accent); } .ctx-item.primary .ic { color:var(--accent); }
@@ -368,14 +494,6 @@ body {
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; } .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 ============ */ /* ============ 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); }
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */ /* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; } .code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
@@ -383,7 +501,9 @@ body {
.ce-gutter { padding-top:6px; will-change:transform; } .ce-gutter { padding-top:6px; will-change:transform; }
.ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; } .ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; }
.ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; } .ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; }
.ce-inner { position:relative; width:max-content; min-width:100%; } /* 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 { .ce-pre, .ce-ta {
margin:0; padding:6px 16px 40px 6px; border:0; margin:0; padding:6px 16px 40px 6px; border:0;
font-family:var(--code-font); font-size:var(--code-size); line-height:20px; font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
@@ -394,7 +514,7 @@ body {
position:absolute; inset:0; resize:none; outline:none; overflow:hidden; position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
background:transparent; color:transparent; caret-color:var(--accent); background:transparent; color:transparent; caret-color:var(--accent);
} }
.ce-ta::selection { background:rgba(77,141,255,0.32); } .ce-ta::selection { background:rgba(241,159,63,0.30); }
/* xterm.js host (real terminals) */ /* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); } .term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
@@ -406,6 +526,8 @@ body {
decorative dots are hidden and the bar is made draggable. Interactive controls decorative dots are hidden and the bar is made draggable. Interactive controls
opt back out of the drag region. */ opt back out of the drag region. */
.titlebar { -webkit-app-region: drag; padding-left: 82px; } .titlebar { -webkit-app-region: drag; padding-left: 82px; }
/* Fullscreen: no traffic lights, so the project name moves back to the edge. */
.titlebar.fullscreen { padding-left: 12px; }
.titlebar .traffic { display: none; } .titlebar .traffic { display: none; }
.titlebar button, .titlebar button,
.titlebar input, .titlebar input,
@@ -415,3 +537,74 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; } * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
} }
/* ============ project launcher (no project open) ============ */
.launcher { position:fixed; inset:0; background:var(--bg-0); display:flex; align-items:center; justify-content:center; }
.launcher-drag { position:absolute; top:0; left:0; right:0; height:40px; -webkit-app-region:drag; }
.launcher-card { width:560px; max-width:92vw; max-height:80vh; display:flex; flex-direction:column; background:var(--bg-2); border:1px solid var(--border-2); border-radius:14px; box-shadow:0 28px 80px rgba(0,0,0,.55); overflow:hidden; -webkit-app-region:no-drag; }
.lp-head { display:flex; align-items:center; gap:12px; padding:18px 20px; border-bottom:1px solid var(--border); }
.lp-title { display:flex; flex-direction:column; line-height:1.3; }
.lp-title b { font-size:16px; color:var(--fg-0); font-weight:600; }
.lp-title span { font-size:12px; color:var(--fg-3); }
.lp-list { overflow:auto; padding:6px; flex:1; min-height:0; }
.lp-sec { padding:10px 10px 4px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); }
.lp-row { display:flex; align-items:center; gap:11px; padding:9px 10px; border-radius:8px; cursor:pointer; }
.lp-row:hover { background:var(--hover); }
.lp-row.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }
.lp-ic { flex:0 0 auto; width:22px; height:22px; display:flex; align-items:center; justify-content:center; color:var(--fg-2); }
.lp-new .lp-ic { color:var(--accent); }
.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; }
.lp-name { font-size:13px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; }
.lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); }
/* ============ keyboard-shortcuts (help) modal ============ */
/* Project note (.notes.txt). Capped at 1000px so the text stays readable on a
wide screen; the height fills nearly the whole window, with a floor for small
ones, because a note is usually long. */
.notes-modal { width:1000px; max-width:92vw; height:calc(100vh - 116px); min-height:260px;
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; }
.notes-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.notes-modal .pi svg { flex:0 0 auto; }
.notes-modal .hist-title { color:var(--fg-1); font-size:14px; }
.notes-modal .notes-file { flex:1; min-width:0; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
.notes-modal .notes-hint { flex:0 0 auto; display:flex; align-items:center; gap:6px; font-size:11.5px; color:var(--fg-2); }
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
padding:14px 16px; color:var(--fg-1); font-family:var(--code-font); font-size:var(--code-size); line-height:20px; }
.notes-input::placeholder { color:var(--fg-3); }
.help-modal { width:520px; 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; display:flex; flex-direction:column; }
.help-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.help-modal .pi svg { flex:0 0 auto; }
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.help-list { max-height:62vh; overflow:auto; padding:8px 6px; }
.help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
.help-row:hover { background:var(--hover); }
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
.help-keys kbd { min-width:20px; text-align:center; }
.help-label { font-size:12.5px; color:var(--fg-2); }
/* title-bar icon-only button (help ?) */
.tb-icon { padding:5px 7px; }
/* confirm dialog (delete, etc.) */
.confirm-modal { width:420px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:12px; box-shadow:0 24px 70px rgba(0,0,0,.55); padding:18px 20px; }
.cf-title { font-size:14px; font-weight:600; color:var(--fg-0); }
.cf-body { margin-top:8px; font-size:12.5px; line-height:1.45; color:var(--fg-2); font-family:var(--mono); word-break:break-all; }
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
.cf-yes:hover { background:#f6b35f; color:#201608; }
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
.cf-yes.danger { background:var(--del); color:#fff; }
.cf-yes.danger:hover { background:#e8797a; }
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
/* search: active result column + file-name selection */
.sc-head .col-kbd { margin-left:auto; opacity:.55; }
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
.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-line); opacity:1; }
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }

View File

@@ -8,6 +8,9 @@ import React, { useEffect, useRef, useState } from 'react'
import { Terminal as XTerm } from '@xterm/xterm' import { Terminal as XTerm } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { ContextMenu } from './overlays'
import type { Menu } from './overlays'
import { Icon } from './components'
let _lid = 0 let _lid = 0
export const lid = (): number => ++_lid export const lid = (): number => ++_lid
@@ -20,7 +23,8 @@ const THEME = {
foreground: '#e6e8ea', foreground: '#e6e8ea',
cursor: '#4d8dff', cursor: '#4d8dff',
cursorAccent: '#1a1c1f', cursorAccent: '#1a1c1f',
selectionBackground: 'rgba(77,141,255,0.32)', selectionBackground: 'rgba(77,141,255,0.55)',
selectionInactiveBackground: 'rgba(77,141,255,0.40)',
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2', blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c', brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
@@ -29,7 +33,14 @@ const THEME = {
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement { export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
const hostRef = useRef<HTMLDivElement>(null) const hostRef = useRef<HTMLDivElement>(null)
const [live, setLive] = useState(kind === 'agent') const termRef = useRef<XTerm | null>(null)
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(() => { useEffect(() => {
const bridge = window.helder const bridge = window.helder
@@ -48,9 +59,18 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
scrollback: 5000, scrollback: 5000,
allowProposedApi: true, allowProposedApi: true,
}) })
termRef.current = term
const fit = new FitAddon() const fit = new FitAddon()
term.loadAddon(fit) term.loadAddon(fit)
term.open(host) term.open(host)
// 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)
})
}
try { fit.fit() } catch { /* host not measured yet */ } try { fit.fit() } catch { /* host not measured yet */ }
let disposed = false let disposed = false
@@ -61,7 +81,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
function onPaste(e: Event): void { function onPaste(e: Event): void {
if (kind !== 'agent' || !bridge || id < 0) return if (kind !== 'agent' || !bridge || id < 0) return
const text = (e as CustomEvent<string>).detail const text = (e as CustomEvent<string>).detail
bridge.pty.write(id, '\x1b[200~' + text + '\n\x1b[201~') // 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() term.focus()
} }
@@ -76,7 +99,15 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
} }
offData = bridge.pty.onData((tid, data) => { if (tid === id) term.write(data) }) 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) } }) 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) => bridge.pty.write(id, d)) 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)) term.onResize(({ cols, rows }) => bridge.pty.resize(id, cols, rows))
if (kind === 'agent') window.addEventListener('agentPaste', onPaste) if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
}) })
@@ -95,19 +126,38 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
offExit() offExit()
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste) if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
if (bridge && id >= 0) bridge.pty.kill(id) if (bridge && id >= 0) bridge.pty.kill(id)
termRef.current = null
term.dispose() term.dispose()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// 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 ( return (
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}> <div className="term-pane" style={{ flex: 1, minHeight: 0 }}
<div className="term-head"> onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
<span className={'dot' + (live ? ' live' : '')}></span> onContextMenu={onContextMenu}>
<span className="lbl">{kind === 'agent' ? 'claude' : 'zsh'}</span>
<span className="tag">{kind === 'agent' ? 'agent session' : '— shell'}</span>
</div>
<div className="term-xterm" ref={hostRef} /> <div className="term-xterm" ref={hostRef} />
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
</div> </div>
) )
} }

View File

@@ -45,6 +45,21 @@ export interface Change {
add: number add: number
del: number del: number
deleted: boolean 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 { export interface Project {
@@ -65,17 +80,23 @@ export interface ViewLine {
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
} }
export type DiffMode = 'original' | 'updated' | 'diff'
/** Effective project settings (mirrors src/main/config.ts). */ /** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number } editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean }
} }
export const DEFAULT_CONFIG: HelderConfig = { export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true },
} }

45
sync_helder.sh Executable file
View 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"))"

View File

@@ -0,0 +1,96 @@
// @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())
})
})

108
test/app.test.tsx Normal file
View 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))
})
})

54
test/config.test.ts Normal file
View File

@@ -0,0 +1,54 @@
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'
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')
})
})

71
test/diff.test.ts Normal file
View File

@@ -0,0 +1,71 @@
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)
})
})

View File

@@ -0,0 +1,57 @@
// @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('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
const c = await openChanged()
fireEvent.click(c.querySelector('.split-btn')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
expect(c.querySelector('.split-pane.left')).toBeTruthy()
expect(c.querySelector('.split-pane.right')).toBeTruthy()
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
})
})

83
test/editor-tab.test.tsx Normal file
View 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))
})
})

152
test/fs.test.ts Normal file
View File

@@ -0,0 +1,152 @@
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, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, 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()
})
})

25
test/fuzzy.test.ts Normal file
View 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([])
})
})

168
test/git-two-rows.test.tsx Normal file
View File

@@ -0,0 +1,168 @@
// @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'
const HEAD = 'a\nb\nc\n'
const INDEX = 'a\nSTAGED\nc\n'
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
/** Minimal preload bridge: enough for the store to boot with real git rows. */
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
readDir: async () => [],
files: async () => ({ 'demo.txt': DISK }),
read: async () => DISK,
imageDataUrl: async () => '',
write: async () => {},
delete: async () => {},
create: async () => {},
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
// Exactly what git-service now returns for porcelain "MM".
load: async () => ({
branch: 'main',
changes: [
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
}),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onProjectChanged: off,
onConfigChanged: off,
onRefresh: off,
}
}
beforeAll(() => {
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()
delete (window as unknown as { helder?: unknown }).helder
})
/** Row text of the view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
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
}
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()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('b')
expect(text).toContain('STAGED')
// The later edit is not part of what is staged, so it must not show here.
expect(text).not.toContain('AFTER-STAGING')
})
it('Diff on the unstaged row compares the staged copy with disk', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('AFTER-STAGING')
// 'b' was already replaced before staging, so this half must not mention it.
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
})
it('labels which pair the diff is comparing', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.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
View 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()
})
})

60
test/highlight.test.ts Normal file
View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { HL } from '../src/renderer/src/highlight'
describe('HL.ext', () => {
it('extracts a normal extension', () => {
expect(HL.ext('src/a/b.php')).toBe('php')
expect(HL.ext('x.TSX')).toBe('tsx')
})
it('treats dotfiles like .env specially', () => {
expect(HL.ext('.env')).toBe('env')
expect(HL.ext('config/.env.local')).toBe('env')
})
it('returns empty for no extension', () => {
expect(HL.ext('Makefile')).toBe('')
})
})
describe('HL.langFor / langLabel', () => {
it('maps known extensions to Prism languages', () => {
expect(HL.langFor('a.php')).toBe('php')
expect(HL.langFor('a.ts')).toBe('typescript')
expect(HL.langFor('a.py')).toBe('python')
expect(HL.langFor('a.unknownext')).toBeNull()
})
it('produces human labels', () => {
expect(HL.langLabel('a.php')).toBe('PHP')
expect(HL.langLabel('a.tsx')).toBe('TypeScript')
expect(HL.langLabel('Makefile')).toBe('Plain Text')
})
})
describe('HL.iconFor', () => {
it('uses name-specific icons', () => {
expect(HL.iconFor('composer.json').t).toBe('co')
expect(HL.iconFor('package.json').t).toBe('pk')
})
it('falls back to extension icons', () => {
expect(HL.iconFor('x.php').c).toBe('#a78bdb')
})
it('falls back to first two letters for unknown types', () => {
expect(HL.iconFor('weird.zzz').t).toBe('we')
})
})
describe('HL.escapeHtml', () => {
it('escapes html-significant characters', () => {
expect(HL.escapeHtml('<a> & </a>')).toBe('&lt;a&gt; &amp; &lt;/a&gt;')
})
})
describe('HL.hlText (Prism)', () => {
it('wraps php keywords in token spans (markup-templating loaded first)', () => {
const out = HL.hlText('<?php class A {}', 'php')
expect(out).toContain('token')
expect(out).toContain('class')
})
it('escapes when no grammar is available', () => {
expect(HL.hlText('<x>', null)).toBe('&lt;x&gt;')
})
})

151
test/logger.test.ts Normal file
View File

@@ -0,0 +1,151 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { _resetLogger, formatErr, formatLine, getLogPath, initLogger, log, logger } from '../src/main/logger'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'helder-log-'))
_resetLogger()
})
afterEach(() => {
_resetLogger()
rmSync(dir, { recursive: true, force: true })
})
function read(): string {
return readFileSync(join(dir, 'helder.log'), 'utf8')
}
describe('formatLine', () => {
it('lays out ts, padded level, pid, scope and message', () => {
const line = formatLine('info', 'git', 'loaded', undefined, 42, '2026-07-15T10:00:00.000Z')
expect(line).toBe('2026-07-15T10:00:00.000Z INFO 42 git loaded\n')
})
it('appends context as JSON', () => {
const line = formatLine('warn', 'ipc', 'slow', { ms: 1200 }, 7, '2026-07-15T10:00:00.000Z')
expect(line).toContain('{"ms":1200}')
expect(line.endsWith('\n')).toBe(true)
})
it('indents a multi-line message so a continuation never looks like a new entry', () => {
// Electron's own console warnings arrive with embedded newlines.
const line = formatLine('warn', 'console', 'line one\nline two', undefined, 1, 'T')
expect(line).toBe('T WARN 1 console line one\n line two\n')
// Every line after the first is indented → an entry always starts at col 0.
for (const l of line.trimEnd().split('\n').slice(1)) expect(l.startsWith(' ')).toBe(true)
})
it('indents multi-line context too', () => {
const line = formatLine('error', 'x', 'boom', { stack: 'a\nb' }, 1, 'T')
// JSON escapes the \n inside the string value, so this stays a single line.
expect(line.split('\n').filter(Boolean)).toHaveLength(1)
})
})
describe('formatErr', () => {
it('keeps message and stack', () => {
const e = new Error('nope')
const out = formatErr(e)
expect(out.message).toBe('nope')
expect(out.stack).toContain('nope')
})
it('follows cause — the line that usually explains the failure', () => {
const root = new Error('EACCES')
const e = new Error('save failed', { cause: root })
expect(formatErr(e).cause).toContain('EACCES')
})
it('survives a thrown string', () => {
expect(formatErr('just a string').message).toBe('just a string')
})
it('survives a thrown non-Error object', () => {
expect(formatErr({ code: 7 }).message).toBe('{"code":7}')
})
it('does not throw on circular values', () => {
const a: Record<string, unknown> = {}
a.self = a
expect(() => formatErr(a)).not.toThrow()
expect(formatErr(a).message).toContain('Circular')
})
})
describe('log', () => {
it('creates the file and appends lines', () => {
initLogger({ dir })
logger.info('boot', 'hello')
logger.warn('boot', 'careful')
const out = read()
expect(out).toContain('INFO')
expect(out).toContain('hello')
expect(out).toContain('WARN')
expect(out.trim().split('\n')).toHaveLength(2)
})
it('creates a missing directory', () => {
const nested = join(dir, 'a', 'b')
initLogger({ dir: nested })
logger.info('x', 'y')
expect(existsSync(join(nested, 'helder.log'))).toBe(true)
})
it('records the error with its stack', () => {
initLogger({ dir })
logger.error('save', 'write failed', new Error('EACCES: permission denied'), { path: 'a.ts' })
const out = read()
expect(out).toContain('EACCES: permission denied')
expect(out).toContain('"path":"a.ts"')
expect(out).toContain('"stack"')
})
it('honours the level floor', () => {
initLogger({ dir, level: 'warn' })
logger.debug('x', 'debug line')
logger.info('x', 'info line')
logger.error('x', 'error line')
const out = read()
expect(out).not.toContain('debug line')
expect(out).not.toContain('info line')
expect(out).toContain('error line')
})
it('is a no-op before initLogger rather than throwing', () => {
expect(() => logger.info('x', 'y')).not.toThrow()
expect(getLogPath()).toBeNull()
})
it('exposes the active path', () => {
initLogger({ dir })
expect(getLogPath()).toBe(join(dir, 'helder.log'))
})
it('never throws even when the log path is unwritable', () => {
// Point at a path whose parent is a FILE: every append must fail.
const wall = join(dir, 'wall')
writeFileSync(wall, 'x')
initLogger({ dir: join(wall, 'sub') })
expect(() => logger.error('x', 'still fine')).not.toThrow()
})
})
describe('rotation', () => {
it('rolls the file once it passes the size cap and keeps writing', () => {
initLogger({ dir })
const big = 'x'.repeat(4000)
// 2MB cap / ~4KB per line → ~525 lines to trip it. 700 is comfortably past.
for (let i = 0; i < 700; i++) log('info', 'bulk', big)
expect(existsSync(join(dir, 'helder.1.log'))).toBe(true)
// The live file is the post-rotation one and is still being appended to.
logger.info('after', 'still logging')
expect(read()).toContain('still logging')
// And it's small again — proof the rotation actually moved the bytes.
expect(read().length).toBeLessThan(4000 * 700)
})
})

73
test/markdown.test.ts Normal file
View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { renderMarkdown } from '../src/renderer/src/markdown'
describe('renderMarkdown', () => {
it('renders headings, bold, italic and links', () => {
const html = renderMarkdown('# Title\n\nSome **bold** and *italic* and [a](https://x.com).')
expect(html).toContain('<h1>Title</h1>')
expect(html).toContain('<strong>bold</strong>')
expect(html).toContain('<em>italic</em>')
expect(html).toContain('<a href="https://x.com" target="_blank" rel="noreferrer">a</a>')
})
it('restores code spans without colliding with surrounding digits', () => {
// " 0 " around the text used to clash with the placeholder index — guard it.
const html = renderMarkdown('I have 0 cats and `code` and 1 dog.')
expect(html).toContain('<code>code</code>')
expect(html).toContain('I have 0 cats')
expect(html).toContain('1 dog.')
expect(html).not.toContain('undefined')
})
it('escapes HTML and never passes through raw tags', () => {
const html = renderMarkdown('A <script>alert(1)</script> tag.')
expect(html).toContain('&lt;script&gt;')
expect(html).not.toContain('<script>')
})
it('drops dangerous link schemes but keeps the text', () => {
const html = renderMarkdown('[click](javascript:alert(1))')
expect(html).not.toContain('javascript:')
expect(html).toContain('click')
})
it('highlights fenced code blocks', () => {
const html = renderMarkdown('```js\nconst a = 1\n```')
expect(html).toContain('<pre class="md-code">')
expect(html).toContain('const')
})
it('renders unordered and ordered lists', () => {
expect(renderMarkdown('- a\n- b')).toContain('<ul><li>a</li><li>b</li></ul>')
expect(renderMarkdown('1. a\n2. b')).toContain('<ol><li>a</li><li>b</li></ol>')
})
it('renders a GFM table with header, body and inline markup', () => {
const html = renderMarkdown('| a | b |\n|---|---|\n| 1 | `x` |\n| 2 | **y** |')
expect(html).toContain('<table class="md-table">')
expect(html).toContain('<thead><tr><th>a</th><th>b</th></tr></thead>')
expect(html).toContain('<td>1</td><td><code>x</code></td>')
expect(html).toContain('<strong>y</strong>')
})
it('applies column alignment from the delimiter row', () => {
const html = renderMarkdown('| l | c | r |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |')
expect(html).toContain('<th style="text-align:left">l</th>')
expect(html).toContain('<th style="text-align:center">c</th>')
expect(html).toContain('<th style="text-align:right">r</th>')
expect(html).toContain('<td style="text-align:center">2</td>')
})
it('handles escaped pipes, ragged rows and pipe-less prose after the table', () => {
const html = renderMarkdown('| a | b |\n|---|---|\n| x \\| y | 2 |\n| short |\n\nAfter.')
expect(html).toContain('<td>x | y</td>')
expect(html).toContain('<td>short</td><td></td>')
expect(html).toContain('<p>After.</p>')
})
it('leaves a pipe-bearing paragraph alone when no delimiter row follows', () => {
const html = renderMarkdown('a | b\nnot a table')
expect(html).not.toContain('<table')
expect(html).toContain('<p>a | b not a table</p>')
})
})

200
test/notes.test.tsx Normal file
View File

@@ -0,0 +1,200 @@
// @vitest-environment jsdom
//
// The project note: ⌘N opens it, the text lands in <project>/.notes.txt, and it
// is written whenever the window loses focus.
import { afterEach, beforeAll, beforeEach, 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'
let stored = ''
let writes: string[] = []
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
readDir: async () => [], files: async () => ({}), read: async () => '',
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
create: async () => {}, mkdir: async () => {},
},
shell: { reveal: noop },
notes: {
read: async () => stored,
write: async (t: string) => { stored = t; writes.push(t) },
},
git: {
load: async () => ({ branch: 'main', changes: [] }),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
}
}
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(() => { stored = ''; writes = []; stubBridge() })
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
})
async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
return c
}
function pressCmdN(): void {
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', metaKey: true })) })
}
/** Open the note. The shortcut is registered in an effect, which React may not
* have flushed yet when the first paint lands, so keep pressing until it takes.
* A second ⌘N with the overlay already open is a no-op. */
async function openNote(c: HTMLElement): Promise<HTMLTextAreaElement> {
await waitFor(() => {
pressCmdN()
if (!c.querySelector('.notes-modal')) throw new Error('note not open')
})
return c.querySelector('.notes-input') as HTMLTextAreaElement
}
function blurWindow(): void {
act(() => { window.dispatchEvent(new Event('blur')) })
}
describe('project note', () => {
it('⌘N opens the note overlay', async () => {
const c = await boot()
expect(c.querySelector('.notes-modal')).toBeNull()
await openNote(c)
expect(c.querySelector('.notes-modal .notes-file')?.textContent).toBe('.notes.txt')
})
it('loads the note that is already on disk', async () => {
stored = 'earlier thoughts\n'
const c = await boot()
const ta = await openNote(c)
await waitFor(() => expect(ta.value).toBe('earlier thoughts\n'))
})
it('writes the note when the window loses focus', async () => {
const c = await boot()
const ta = await openNote(c)
fireEvent.change(ta, { target: { value: 'buy milk' } })
expect(writes).toEqual([]) // nothing written while typing
blurWindow()
await waitFor(() => expect(writes).toEqual(['buy milk']))
expect(stored).toBe('buy milk')
})
it('does not rewrite the file when nothing changed', async () => {
stored = 'unchanged'
await boot()
await waitFor(() => expect(stored).toBe('unchanged'))
blurWindow()
blurWindow()
expect(writes).toEqual([])
})
it('Esc closes the note and saves it right away', async () => {
const c = await boot()
const ta = await openNote(c)
fireEvent.change(ta, { target: { value: 'quick capture' } })
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
await waitFor(() => expect(writes).toEqual(['quick capture']))
})
it('⌘P passes the note to the agent, then saves and closes it', async () => {
const c = await boot()
const ta = await openNote(c)
fireEvent.change(ta, { target: { value: 'refactor the policy' } })
const seen: string[] = []
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
window.addEventListener('agentPaste', onPaste)
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
window.removeEventListener('agentPaste', onPaste)
expect(seen).toEqual(['refactor the policy'])
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
await waitFor(() => expect(writes).toEqual(['refactor the policy']))
})
it('⌘P does nothing when the note is empty', async () => {
const c = await boot()
await openNote(c)
const seen: string[] = []
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
window.addEventListener('agentPaste', onPaste)
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
window.removeEventListener('agentPaste', onPaste)
expect(seen).toEqual([])
expect(c.querySelector('.notes-modal')).not.toBeNull()
})
it('the header shows the pass-to-agent hint', async () => {
const c = await boot()
await openNote(c)
const hint = c.querySelector('.notes-modal .notes-hint')
expect(hint?.textContent).toContain('To agent')
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
})
it('the title bar carries the ⌘N note action', async () => {
const c = await boot()
const btn = [...c.querySelectorAll('.titlebar .tb-btn')]
.find((b) => b.textContent?.includes('Note')) as HTMLButtonElement | undefined
expect(btn?.querySelector('kbd')?.textContent).toBe('⌘N')
expect(btn?.className).not.toContain('on')
act(() => { btn?.click() })
await waitFor(() => expect(c.querySelector('.notes-modal')).not.toBeNull())
// The action reads as "on" while the note is open, like the other toggles.
expect(btn?.className).toContain('on')
})
it('keeps the text when reopened', async () => {
const c = await boot()
const ta = await openNote(c)
fireEvent.change(ta, { target: { value: 'still here' } })
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
const again = await openNote(c)
expect(again.value).toBe('still here')
})
})

40
test/search.test.ts Normal file
View File

@@ -0,0 +1,40 @@
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 { listFiles, rgAvailable, searchContent } from '../src/main/search-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
describe('search-service (real ripgrep via dynamic import)', () => {
it('ripgrep is available (catches the ESM require() regression)', async () => {
expect(await rgAvailable()).toBe(true)
})
it('finds content matches with line number + column', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
await simpleGit(dir).init()
await writeFile(join(dir, 'a.ts'), 'const x = 1\nconst balance = 2\n')
await writeFile(join(dir, 'b.ts'), 'nothing relevant\n')
const groups = await searchContent(dir, 'balance')
expect(groups).toHaveLength(1)
expect(groups[0].path).toBe('a.ts')
expect(groups[0].hits[0].no).toBe(2)
expect(groups[0].hits[0].ln).toContain('balance')
expect(groups[0].hits[0].ix).toBe('const '.length)
})
it('returns [] for queries under 2 characters', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
expect(await searchContent(dir, 'a')).toEqual([])
})
it('lists project files', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
await simpleGit(dir).init()
await writeFile(join(dir, 'keep.ts'), 'x')
expect(await listFiles(dir)).toContain('keep.ts')
})
})

105
test/titlebar.test.tsx Normal file
View File

@@ -0,0 +1,105 @@
// @vitest-environment jsdom
//
// The title bar: borderless actions that go accent when on, and the fullscreen
// shift (macOS hides the traffic lights, so the project name moves to the edge).
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, 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'
/** Fires the fullscreen callbacks the App subscribed to. */
let fullscreenCbs: ((on: boolean) => void)[] = []
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
readDir: async () => [], files: async () => ({}), read: async () => '',
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
create: async () => {}, mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
load: async () => ({ branch: 'main', changes: [] }),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onFullscreen: (cb: (on: boolean) => void) => {
fullscreenCbs.push(cb)
return () => { fullscreenCbs = fullscreenCbs.filter((f) => f !== cb) }
},
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
}
}
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(() => { fullscreenCbs = []; stubBridge() })
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
})
async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
return c
}
function setFullscreen(on: boolean): void {
act(() => { fullscreenCbs.forEach((cb) => cb(on)) })
}
describe('title bar', () => {
it('shifts left in fullscreen and back out again', async () => {
const c = await boot()
const bar = c.querySelector('.titlebar') as HTMLElement
expect(bar.className).not.toContain('fullscreen')
await waitFor(() => expect(fullscreenCbs.length).toBe(1))
setFullscreen(true)
expect(bar.className).toContain('fullscreen')
setFullscreen(false)
expect(bar.className).not.toContain('fullscreen')
})
it('shows the state with the accent, not an On/Off badge', async () => {
const c = await boot()
const hidden = [...c.querySelectorAll<HTMLButtonElement>('.titlebar .tb-btn')]
.find((b) => b.textContent?.includes('Hidden')) as HTMLButtonElement
expect(c.querySelector('.titlebar .tb-state')).toBeNull()
expect(hidden.className).not.toContain('on')
act(() => { hidden.click() })
await waitFor(() => expect(hidden.className).toContain('on'))
expect(hidden.textContent).not.toContain('On')
})
})

10
vitest.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.{ts,tsx}'],
// The renderer modules under test (diff, fuzzy, highlight) are DOM-free.
globals: false,
},
})