Compare commits

...

29 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
51 changed files with 5038 additions and 738 deletions

2
.helder/.gitignore vendored Normal file
View File

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

View File

@@ -11,11 +11,12 @@
"confirmDiscard": true, "confirmDiscard": true,
"confirmStage": false, "confirmStage": false,
"confirmUnstage": false, "confirmUnstage": false,
"defaultDiffMode": "diff" "defaultDiffMode": "diff",
"refreshInterval": 10000
}, },
"files": { "files": {
"exclude": [], "exclude": [],
"followGitignore": true "followGitignore": false
}, },
"terminal": { "terminal": {
"shell": null "shell": null

View File

@@ -46,10 +46,12 @@ 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. - **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)
@@ -73,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.
@@ -97,4 +110,6 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design
- `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied). - `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. - `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged.
**The mac build must be ad-hoc signed — `build/adhoc-sign.cjs` (the `afterPack` hook) does this.** `mac.identity: null` skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports `Identifier=Electron`, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with *"Malware Blocked and Moved to Trash"*. A real ad-hoc signature over the whole bundle (with `build/entitlements.mac.plist` for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with `xattr -dr com.apple.quarantine /Applications/Helder.app` or ship a Developer ID build.
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code. Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.

View File

@@ -1,75 +0,0 @@
# Overnight work log
Autonomous session continuing the Helder build while you slept. Everything kept
compiling (`npm run build`), type-checking (`npm run typecheck`) and — new this
session — passing tests (`npm run test`). Git left uncommitted for you to review.
## Plan
1. Packaging (electron-builder)
2. Automated test suite (vitest)
3. Lint + format (eslint + prettier)
4. Robustness + feature polish
5. Developer README
## Progress
### 1. Packaging — done
- Added `electron-builder.yml` (appId `com.blijnder.helder`, mac dmg+zip / win nsis / linux AppImage, unsigned local build).
- Scripts: `npm run pack` (`--dir`), `npm run dist`, `npm run dist:mac`.
- Made the bundled ripgrep path asar-safe (redirect to `app.asar.unpacked`).
- `asarUnpack` for `node-pty` + `@vscode/ripgrep` so the native `.node` and `rg` binary load at runtime.
- Verified: `npm run pack` produced `dist/mac-arm64/Helder.app` (245 MB) with `pty.node` + `rg` correctly unpacked. Unsigned (ad-hoc), default icon. `dist/` gitignored.
### 2. Automated tests — done (vitest)
- Added vitest + `vitest.config.ts`; scripts `npm run test` / `test:watch`. **38 tests, all green.**
- `test/diff.test.ts` — LCS diff: no-change, single change, new/deleted file, side marks, split alignment, trailing-newline.
- `test/fuzzy.test.ts` — subsequence matcher (extracted to `src/renderer/src/fuzzy.ts`).
- `test/highlight.test.ts` — ext/lang/label/icon/escape + Prism php highlighting (confirms markup-templating load order).
- `test/config.test.ts``.helder` defaults regen, sparse deep-merge, theme.css not overwritten.
- `test/fs.test.ts` — tree dirs-first + ignore dirs, content index skips binaries/node_modules, read/write round-trip.
- `test/git.test.ts``classify` unit + real temp-repo integration (modified/untracked/staged, HEAD-vs-worktree text).
### 3. Lint + format — done
- ESLint flat config (`eslint.config.js`): typescript-eslint recommended + react-hooks, prettier-disables, sensible ignores. `npm run lint` = **0 errors** (8 intentional exhaustive-deps warnings).
- Fixed real `no-unused-expressions` violations (short-circuit/ternary-as-statement).
- `.prettierrc.json` added (style: no-semi, single-quote, width 140). Not auto-applied to avoid churn.
### 4. Robustness + feature polish — done
- **Fixed a real `discard` bug**: `git checkout` can't remove a new/untracked file. `discard` now reverts modified→HEAD, restores deleted, and removes new/untracked (staged or not). Covered by new git tests.
- **Close-tab guard**: confirms before closing a tab with unsaved edits (× / middle-click / ⌘W), and clears its buffer on close. Reads fresh state via refs so the ⌘W path is correct too.
- **ErrorBoundary** around the whole app — a render fault shows a dark, recoverable panel (with the message + Reload) instead of a blank window.
- **`editor.tabSize` wired** into the editable buffer (textarea + highlighted pre) and the status bar — config now visibly does something.
- **Layout persistence**: the three column widths + the terminal split fraction persist across launches via localStorage (`persist.ts`), making the README's "positions persist across launches" claim true.
### 5. Developer docs + icon — done
- `README.md` updated from "greenfield" to the real working build: getting-started, scripts table, repository layout.
- App icon generated from Helder's spark mark → `build/icon.png` (1024²); electron-builder embeds `icon.icns` in the packaged app (verified).
### 6. Renderer component tests + CI — done
- Added jsdom + `@testing-library/react`; the terminals (xterm) are mocked so tests stay deterministic.
- `test/app.test.tsx` — workbench renders, open changed file → diff tab, edit → dirty tab, content search returns hits.
- `test/app-interactions.test.tsx`**Pass-on-to-Agent** emits exactly `"<note> <path:line>"` via the `agentPaste` event; **stage → commit** toasts.
- `test/editor-modes.test.tsx` — Updated mode is editable / Original is read-only; Split opens the two-pane overlay and Esc collapses it.
- **49 tests total, all green.**
- `.github/workflows/ci.yml` — runs typecheck · lint · test · build on push/PR.
### 7. Finish to the DESIGN.md spec — done
Audited `DESIGN.md` section by section and closed every remaining behavioral gap:
- **Session restore** (`session.restoreOnLaunch`, §9) — open tabs, active tab, and per-tab view modes are restored per project (persisted in localStorage, keyed by root); splitter layout already persisted.
- **Close-dirty tab** (§5) — now a real **Save / Don't Save / Cancel** native dialog (was discard-or-cancel). `⌘S` save unchanged.
- **Unsaved indicator** (§5) — a **dot in place of the close control** (× returns on hover), matching the spec exactly (was a dot after the name).
- **New config keys, all wired**: `git.confirmStage` / `git.confirmUnstage` (default off, prompt when on), `git.defaultDiffMode` (drives the starting mode), `files.exclude` + `files.followGitignore`, `session.restoreOnLaunch`.
- **Unified file source**: `rg --files` is now the single source for the **Explorer tree + content index + search**, so **gitignore and `files.exclude` are honored consistently everywhere** (fs-walk fallback when ripgrep is absent). Dotfiles (`.env`, `.gitignore`) included via `--hidden`.
- **⌘P** aliases the unified search — no separate Go-to-File overlay, per the resolved decision (CLAUDE.md/README).
- Tests now **51** (added `buildTreeFromPaths`, gitignore-in-repo, updated dirty-tab class).
### 8. Live-run bugfix (found by running `npm run dev`)
- **Symptom:** the real Electron window showed mock "console" data and both terminals said "not available in browser preview" — i.e. `window.helder` was undefined in the actual app, so the renderer fell back to mock mode.
- **Cause:** electron-vite built the preload as `out/preload/index.mjs`, but `main/index.ts` loaded `../preload/index.js` (wrong extension) → Electron silently loaded no preload → no bridge.
- **Fix:** build the preload as **CommonJS `index.cjs`** (loads synchronously before the page, so `contextBridge` is exposed by the time React mounts) and point `main` at `../preload/index.cjs`. Verified the built `index.cjs` exposes `helder` and main references it. **Re-run `npm run dev` to confirm the live window now loads the real project + terminals.**
## Final state (all green)
- `npm run typecheck` ✓ · `npm run lint` ✓ (0 errors, 8 intentional warnings) · `npm test` ✓ (51) · `npm run build` ✓ · `npm run pack` ✓ (`Helder.app` with icon + unpacked native binaries)
- **DESIGN.md is now fully implemented** (the only intentional exception is the separate Go-to-File overlay, replaced by ⌘P→unified-search per the resolved decision).
- Everything left **uncommitted** for your review (laptop is read-only on git).
- The only thing not exercisable in this headless session remains the live Electron GUI — `npm run dev` to see it.

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>

View File

@@ -8,9 +8,15 @@ files:
- out/** - out/**
- package.json - package.json
# Native / spawned binaries must live outside the asar to load at runtime. # 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: asarUnpack:
- '**/node_modules/node-pty/**' - '**/node_modules/node-pty/**'
- '**/node_modules/@vscode/ripgrep/**' - '**/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: mac:
category: public.app-category.developer-tools category: public.app-category.developer-tools
target: target:
@@ -19,8 +25,20 @@ mac:
# Local/unsigned build: ad-hoc signed by electron-builder, no notarization. # Local/unsigned build: ad-hoc signed by electron-builder, no notarization.
identity: null identity: null
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
files:
# Keep the cross-build leftovers (see win:) out of the mac package.
- '!**/node_modules/@vscode/ripgrep-win32-*/**'
win: win:
target: nsis 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: linux:
target: AppImage target: AppImage
category: Development category: Development

View File

@@ -29,6 +29,12 @@ export default tseslint.config(
files: ['test/**/*.{ts,tsx}'], files: ['test/**/*.{ts,tsx}'],
languageOptions: { globals: { ...globals.node, ...globals.browser } }, 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: { rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],

Binary file not shown.

162
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@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"
@@ -3387,6 +3387,31 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/app-builder-lib": { "node_modules/app-builder-lib": {
"version": "26.15.3", "version": "26.15.3",
"resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz",
@@ -3721,6 +3746,18 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": { "node_modules/bluebird": {
"version": "3.7.2", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -3750,6 +3787,18 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.2", "version": "4.28.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
@@ -3991,18 +4040,39 @@
} }
}, },
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "5.0.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"readdirp": "^5.0.0" "anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
}, },
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">= 8.10.0"
}, },
"funding": { "funding": {
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
} }
}, },
"node_modules/chownr": { "node_modules/chownr": {
@@ -5288,6 +5358,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/find-up": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -5369,7 +5451,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -5858,11 +5939,22 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5882,7 +5974,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -5891,6 +5982,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-potential-custom-element-name": { "node_modules/is-potential-custom-element-name": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -6811,6 +6911,15 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-url": { "node_modules/normalize-url": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@@ -7330,16 +7439,27 @@
} }
}, },
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "5.0.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 20.19.0" "node": ">=8.6"
}, },
"funding": { "funding": {
"type": "individual", "url": "https://github.com/sponsors/jonschlinkert"
"url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-directory": { "node_modules/require-directory": {
@@ -8024,6 +8144,18 @@
"tmp": "^0.2.0" "tmp": "^0.2.0"
} }
}, },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",

View File

@@ -26,7 +26,7 @@
"@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"

View File

@@ -15,7 +15,7 @@ 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; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean } session: { restoreOnLaunch: boolean }
@@ -24,8 +24,8 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = { export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true }, files: { exclude: [], followGitignore: false },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true }, session: { restoreOnLaunch: true },
} }
@@ -46,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)
} }
@@ -83,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,6 +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 } from './search-service' import { listFiles, rgAvailable } from './search-service'
export interface FileNode { export interface FileNode {
name: string name: string
@@ -40,31 +40,43 @@ function sortTree(node: FileNode): void {
for (const c of node.children) sortTree(c) for (const c of node.children) sortTree(c)
} }
/** Build a nested tree from relative file paths (dirs first, alphabetical). */ /**
export function buildTreeFromPaths(rootName: string, paths: string[]): FileNode { * 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 root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
const dirs = new Map<string, FileNode>([['', root]]) const dirs = new Map<string, FileNode>([['', root]])
for (const rel of paths) {
/** 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) const parts = rel.split('/').filter(Boolean)
let parentPath = '' let parentPath = ''
let parent = root let parent = root
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const isFile = i === parts.length - 1
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i] const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
if (isFile) { let dir = dirs.get(curPath)
parent.children!.push({ name: parts[i], type: 'file', path: curPath }) if (!dir) {
} else { dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
let dir = dirs.get(curPath) dirs.set(curPath, dir)
if (!dir) { parent.children!.push(dir)
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] }
dirs.set(curPath, dir)
parent.children!.push(dir)
}
parent = dir
parentPath = curPath
} }
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) sortTree(root)
return root return root
} }
@@ -73,14 +85,89 @@ function rootName(root: string): string {
return root.split(sep).filter(Boolean).pop() || root return root.split(sep).filter(Boolean).pop() || root
} }
/** Project tree. Primary: rg file list (honors gitignore + excludes). Fallback: /** Project tree. Primary: rg file list (honors gitignore + excludes) plus the
* a plain recursive walk (when ripgrep is unavailable). */ * 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 paths = await listFiles(root).catch(() => [] as string[]) const [paths, emptyDirs] = await Promise.all([
if (paths.length) return buildTreeFromPaths(rootName(root), paths) 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) } 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[]> {
let entries: import('node:fs').Dirent[] let entries: import('node:fs').Dirent[]
try { try {
@@ -115,11 +202,72 @@ 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')
} }
/**
* Create a new, empty text file (relative path). Creates parent folders as
* needed, refuses to escape the project root, and throws if the file already
* exists so an accidental name collision never clobbers existing content.
*/
export async function createProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel)
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
const existing = await stat(target).catch(() => null)
if (existing) throw new Error('file already exists')
await mkdir(dirname(target), { recursive: true })
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
}
/**
* Create a new, empty folder (relative path). Creates parent folders as needed,
* refuses to escape the project root, and throws if the folder already exists so
* a name collision is surfaced rather than silently swallowed. The empty folder
* shows up in the tree immediately (see `listEmptyDirs`).
*/
export async function createProjectDir(root: string, rel: string): Promise<void> {
const target = join(root, rel)
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
const existing = await stat(target).catch(() => null)
if (existing) throw new Error('folder already exists')
await mkdir(target, { recursive: true })
}
/** 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. * In-memory content index of all (small, text) files — powers content viewing.
* Primary: read the rg file list; fallback: walk. Capped for large repos. * Primary: read the rg file list; fallback: walk. Capped for large repos.

View File

@@ -1,6 +1,25 @@
import { readFile, rm } 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. */ /**
export 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,50 +179,134 @@ 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])
}
/**
* 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 }
} }
/** /**
@@ -109,13 +315,12 @@ export async function commit(root: string, message: string): Promise<void> {
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk * - not in HEAD → a new file (staged or untracked): unstage + delete from disk
*/ */
export async function discard(root: string, paths: string[]): Promise<void> { export async function discard(root: string, paths: string[]): Promise<void> {
const g = git(root)
for (const p of paths) { for (const p of paths) {
const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false) const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`])
if (inHead) { if (code === 0) {
await g.checkout(['HEAD', '--', p]) await git(root, ['checkout', 'HEAD', '--', p])
} else { } else {
try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ } await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result
await rm(join(root, p), { force: true }) 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 { readFileSync, statSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron' import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar' import { watch, type FSWatcher } from 'chokidar'
import { 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,14 +72,57 @@ 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 /** Show the folder picker; if a new folder is chosen, switch the project
* (config + watchers). Returns whether the project changed. */ * (config + watchers). Returns whether the project changed. */
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> { async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
const next = await openDialog(win) const next = await openDialog(win)
if (!next) return false if (!next) return false
await resolveConfig(getRoot()) await resolveConfig(next)
startWatcher() startWatcher()
startConfigWatcher() startConfigWatcher()
startGitWatcher()
syncWindowTitle()
return true return true
} }
@@ -51,6 +132,11 @@ function buildAppMenu(): Menu {
{ {
label: 'File', label: 'File',
submenu: [ submenu: [
{
label: 'New Window',
accelerator: 'CmdOrCtrl+Shift+N',
click: () => spawnInstance(),
},
{ {
label: 'Open Folder…', label: 'Open Folder…',
accelerator: 'CmdOrCtrl+O', accelerator: 'CmdOrCtrl+O',
@@ -64,12 +150,58 @@ function buildAppMenu(): Menu {
], ],
}, },
{ role: 'editMenu' }, { role: 'editMenu' },
{ role: 'viewMenu' }, {
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: '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) 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()
@@ -85,37 +217,112 @@ 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)
} }
/**
* ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
* `catch {}` that showed a generic toast ("Create failed") and dropped the
* actual cause, so the log records the channel, its args and the error, then
* RETHROWS so the renderer keeps behaving exactly as before.
*
* Slow calls get a line too: an FS/git handler blocking for seconds is the
* symptom that precedes a beachball, and it's invisible otherwise.
*/
const SLOW_MS = 1000
function handle(channel: string, fn: (e: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown): void {
ipcMain.handle(channel, async (e, ...args) => {
const started = Date.now()
try {
const out = await fn(e, ...(args as never[]))
const ms = Date.now() - started
if (ms >= SLOW_MS) logger.warn('ipc', `${channel} slow`, { ms, args: previewArgs(args) })
return out
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args), ms: Date.now() - started })
throw err
}
})
}
/** Log-safe args: file CONTENT (fs:write) would swamp the log, so cap length. */
function previewArgs(args: unknown[]): unknown[] {
return args.map((a) => (typeof a === 'string' && a.length > 120 ? `${a.slice(0, 120)}… (${a.length} chars)` : a))
}
/** Same, for the fire-and-forget `ipcMain.on` channels. */
function on(channel: string, fn: (e: Electron.IpcMainEvent, ...args: never[]) => void): void {
ipcMain.on(channel, (e, ...args) => {
try {
fn(e, ...(args as never[]))
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args) })
}
})
}
function registerIpc(): void { function registerIpc(): void {
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() })) handle('project:current', () => ({ root: getRoot(), name: getName() }))
ipcMain.handle('project:open', async (e) => { handle('project:open', async (e) => {
await openFolderFlow(BrowserWindow.fromWebContents(e.sender)) await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
return { root: getRoot(), name: getName() } 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() }
})
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())
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => { 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 win = BrowserWindow.fromWebContents(e.sender)
const opts: Electron.MessageBoxOptions = { const opts: Electron.MessageBoxOptions = {
type: 'warning', type: 'warning',
@@ -130,6 +337,13 @@ function registerIpc(): void {
}) })
} }
/** 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 {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1680, width: 1680,
@@ -138,6 +352,7 @@ 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: {
@@ -148,8 +363,22 @@ function createWindow(): void {
}, },
}) })
// 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' }
@@ -163,22 +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()) 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,4 +1,3 @@
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' import { getConfig } from './config'
@@ -6,18 +5,23 @@ import { getConfig } from './config'
/** ripgrep is the single source of "what files are in the project": it powers /** ripgrep is the single source of "what files are in the project": it powers
* content search, the file-name list, AND the Explorer tree / content index * content search, the file-name list, AND the Explorer tree / content index
* (via fs-service) — so gitignore + files.exclude are honored everywhere the * (via fs-service) — so gitignore + files.exclude are honored everywhere the
* same way. Substring (fixed-string), smart-case search. */ * same way. Substring (fixed-string), smart-case search.
const require = createRequire(import.meta.url) *
* @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for
let rgPath: string | null = null * ESM and CJS) and cache the resolved binary path. */
try { const rgPathPromise: Promise<string | null> = (async () => {
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath try {
// When packaged the binary is unpacked from the asar; rgPath still points const mod = await import('@vscode/ripgrep')
// inside app.asar, so redirect it. No-op in dev (path has no app.asar). let p = (mod as { rgPath: string }).rgPath
if (rgPath) rgPath = rgPath.replace(/\bapp\.asar\b/, 'app.asar.unpacked') // When packaged the binary is unpacked from the asar; rgPath still points
} catch (e) { // inside app.asar, so redirect it. No-op in dev (path has no app.asar).
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message) 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[] }
@@ -31,8 +35,8 @@ const BASE_IGNORE = [
const MAX_FILES = 400 const MAX_FILES = 400
const MAX_LINE = 1000 const MAX_LINE = 1000
export function rgAvailable(): boolean { export async function rgAvailable(): Promise<boolean> {
return !!rgPath return !!(await rgPathPromise)
} }
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */ /** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
@@ -49,9 +53,10 @@ 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', '--hidden', '--json', '--fixed-strings', '--smart-case', '--hidden',
'--max-count', '50', '--max-columns', '2000', '--max-count', '50', '--max-columns', '2000',
@@ -89,11 +94,14 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
} }
/** All project files (relative paths), honoring gitignore + excludes. Includes /** All project files (relative paths), honoring gitignore + excludes. Includes
* dotfiles (--hidden) so .env etc. show up unless ignored. */ * dotfiles (--hidden) so .env etc. show up unless ignored. Pass `scope` (an
export function listFiles(root: string): Promise<string[]> { * 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', '--hidden', ...ignoreArgs(), '--', 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,6 +73,11 @@ 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'),
@@ -66,6 +88,18 @@ const api = {
ipcRenderer.invoke('dialog:unsavedClose', path), 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()
@@ -79,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 }: {
@@ -48,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
@@ -55,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)
} }
@@ -71,6 +114,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off" <textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off" style={{ tabSize }} 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} />
@@ -81,37 +125,38 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
) )
} }
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 ? ' dirtyclose' : '')}
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
{t.dirty && <span className="tdot" title="Unsaved changes" />} if (!bridge) { setFailed(true); return }
</div> 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>
) )
} }
@@ -170,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()
@@ -178,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
@@ -217,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 }
@@ -227,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
@@ -251,65 +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>Open folder</span><kbd> O</kbd></div> <div><span>Open folder</span><kbd>O</kbd></div>
<div><span>Search files &amp; content</span><kbd> F</kbd></div> <div><span>Recent projects</span><kbd>O</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div> <div><span>Search files &amp; content</span><kbd>F</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</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} />
)} )}
@@ -320,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
@@ -355,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>
@@ -368,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>
@@ -379,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,6 +57,10 @@ 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[]>
@@ -49,8 +68,16 @@ interface HelderBridge {
dialog: { dialog: {
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'> 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

@@ -1,24 +1,29 @@
import React from 'react' import React from 'react'
import { rlog } from './log'
interface State { interface State {
error: Error | null error: Error | null
stack: string | null
} }
/** Catches render-time errors anywhere in the tree and shows a dark, recoverable /** Catches render-time errors anywhere in the tree and shows a dark, recoverable
* panel instead of a blank window. */ * panel instead of a blank window. */
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> { export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
state: State = { error: null } state: State = { error: null, stack: null }
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
return { error } return { error, stack: null }
} }
componentDidCatch(error: Error, info: React.ErrorInfo): void { componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[helder] render error:', error, info.componentStack) // 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 { render(): React.ReactNode {
const { error } = this.state const { error, stack } = this.state
if (!error) return this.props.children if (!error) return this.props.children
return ( return (
<div style={{ <div style={{
@@ -30,11 +35,18 @@ export class ErrorBoundary extends React.Component<{ children: React.ReactNode }
maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left', maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left',
fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)', fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)',
background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap', background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap',
}}>{error.message}</pre> }}>{(error.stack || error.message) + (stack ? '\n' + stack : '')}</pre>
<button onClick={() => location.reload()} style={{ <div style={{ display: 'flex', gap: 8 }}>
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600, <button onClick={() => location.reload()} style={{
padding: '7px 14px', cursor: 'pointer', fontSize: 12, background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
}}>Reload</button> 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> </div>
) )
} }

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

@@ -14,6 +14,10 @@ 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 { 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>

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,7 @@
/* 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 { fuzzy } from './fuzzy'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import type { OpenFile } from './components' import type { OpenFile } from './components'
@@ -13,8 +14,20 @@ 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[] }
@@ -24,23 +37,42 @@ function Highlight({ text, idx }: { text: string; idx: number[] | null }): React
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(() => {
if (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)))
}, []) }, [])
@@ -71,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 }
@@ -85,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() }
} }
@@ -125,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} />
@@ -144,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)}
@@ -155,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>
@@ -180,23 +286,295 @@ 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' : '')}
onMouseEnter={() => setHi(i)}
onClick={() => { it.onClick?.(); onClose() }}> onClick={() => { it.onClick?.(); onClose() }}>
<span className="ic">{it.icon}</span> <span className="ic">{it.icon}</span>
<span>{it.label}</span> <span>{it.label}</span>
@@ -221,11 +599,12 @@ 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('')
@@ -241,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>
@@ -249,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

@@ -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 {
@@ -71,7 +86,7 @@ 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; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean } session: { restoreOnLaunch: boolean }
@@ -80,7 +95,7 @@ export interface HelderConfig {
export const DEFAULT_CONFIG: HelderConfig = { export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true }, files: { exclude: [], followGitignore: true },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true }, 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

@@ -23,14 +23,27 @@ function renderApp(): HTMLElement {
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined { function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text)) 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', () => { describe('Pass on to Agent', () => {
it('inserts "<note> <path:line>" via the agentPaste event', async () => { it('inserts "<note> => <path:line>" via the agentPaste event', async () => {
const received: string[] = [] const received: string[] = []
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) } const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
window.addEventListener('agentPaste', handler) window.addEventListener('agentPaste', handler)
try { try {
const c = renderApp() const c = renderApp()
await expandTo(c, 'public', 'assets')
const treeRow = await waitFor(() => { const treeRow = await waitFor(() => {
const r = find(c, '.tree-row', 'store.js') const r = find(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready') if (!r) throw new Error('tree not ready')
@@ -44,7 +57,7 @@ describe('Pass on to Agent', () => {
}) })
fireEvent.contextMenu(ta) fireEvent.contextMenu(ta)
const pass = await waitFor(() => { const pass = await waitFor(() => {
const item = find(c, '.ctx-item', 'Pass on to Agent') const item = find(c, '.ctx-item', 'Pass on reference')
if (!item) throw new Error('menu not open') if (!item) throw new Error('menu not open')
return item return item
}) })
@@ -57,7 +70,7 @@ describe('Pass on to Agent', () => {
fireEvent.change(input, { target: { value: 'look here' } }) fireEvent.change(input, { target: { value: 'look here' } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
await waitFor(() => expect(received.length).toBeGreaterThan(0)) await waitFor(() => expect(received.length).toBeGreaterThan(0))
expect(received[0]).toBe('look here public/assets/store.js:1') expect(received[0]).toBe('look here => public/assets/store.js:1')
} finally { } finally {
window.removeEventListener('agentPaste', handler) window.removeEventListener('agentPaste', handler)
} }
@@ -65,25 +78,19 @@ describe('Pass on to Agent', () => {
}) })
describe('Stage + commit', () => { describe('Stage + commit', () => {
it('stages a file, commits with a message, and toasts', async () => { it('stages a file and commits via Shift+Enter (no commit button)', async () => {
const c = renderApp() const c = renderApp()
const row = await waitFor(() => { const row = await waitFor(() => {
const r = find(c, '.git-row', 'UserController.php') const r = find(c, '.git-row', 'UserController.php')
if (!r) throw new Error('git not ready') if (!r) throw new Error('git not ready')
return r return r
}) })
const stageBtn = row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')! fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
fireEvent.click(stageBtn) // the commit button is intentionally gone — committing is keyboard-only
expect(c.querySelector('.commit-btn')).toBeNull()
// commit button reflects the staged count once a file is staged
await waitFor(() => expect(find(c, '.commit-btn', 'Commit')?.textContent).toMatch(/Commit\s*\d/))
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')! const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
fireEvent.change(msg, { target: { value: 'wire up balance' } }) fireEvent.change(msg, { target: { value: 'wire up balance' } })
const commitBtn = find(c, '.commit-btn', 'Commit') as HTMLButtonElement fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
expect(commitBtn.disabled).toBe(false)
fireEvent.click(commitBtn)
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy()) await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
}) })
}) })

View File

@@ -33,19 +33,31 @@ function renderApp(): HTMLElement {
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined { function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text)) 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)', () => { describe('App (mock data, jsdom)', () => {
it('renders the four-column workbench with the git change list', async () => { it('renders the four-column workbench with the git change list', async () => {
const c = renderApp() const c = renderApp()
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy()) await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
expect(c.querySelector('.workbench')).toBeTruthy() expect(c.querySelector('.workbench')).toBeTruthy()
expect(c.textContent).toContain('Source Control') expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
expect(c.textContent).toContain('Explorer') expect(c.querySelector('.tree-body')).toBeTruthy() // explorer
// no file open yet // no file open yet
expect(c.textContent).toContain('No file open') expect(c.textContent).toContain('No file open')
}) })
it('opens a changed file from the git panel into a diff tab', async () => { it('opens a changed file from the git panel into the diff view', async () => {
const c = renderApp() const c = renderApp()
const row = await waitFor(() => { const row = await waitFor(() => {
const r = rowWithText(c, '.git-row', 'UserController.php') const r = rowWithText(c, '.git-row', 'UserController.php')
@@ -53,13 +65,16 @@ describe('App (mock data, jsdom)', () => {
return r return r
}) })
fireEvent.click(row) fireEvent.click(row)
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy()) // no tabs anymore — the file opens straight into the editor's diff toolbar,
// changed file → diff toolbar with a status word // 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(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
}) })
it('makes an edited buffer dirty (tab dot)', async () => { it('makes an edited buffer dirty (breadcrumb dot)', async () => {
const c = renderApp() const c = renderApp()
await expandTo(c, 'public', 'assets')
const treeRow = await waitFor(() => { const treeRow = await waitFor(() => {
const r = rowWithText(c, '.tree-row', 'store.js') const r = rowWithText(c, '.tree-row', 'store.js')
if (!r) throw new Error('tree not ready') if (!r) throw new Error('tree not ready')
@@ -71,9 +86,9 @@ describe('App (mock data, jsdom)', () => {
if (!t) throw new Error('editor not ready') if (!t) throw new Error('editor not ready')
return t return t
}) })
expect(c.querySelector('.tab.dirtyclose')).toBeNull() expect(c.querySelector('.tb-dirty')).toBeNull()
fireEvent.change(ta, { target: { value: '// edited\n' } }) fireEvent.change(ta, { target: { value: '// edited\n' } })
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).toBeTruthy()) await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
}) })
it('searches file contents from the search modal', async () => { it('searches file contents from the search modal', async () => {
@@ -86,7 +101,7 @@ describe('App (mock data, jsdom)', () => {
if (!m) throw new Error('modal not open') if (!m) throw new Error('modal not open')
return m as HTMLElement return m as HTMLElement
}) })
const input = within(modal).getByPlaceholderText(/Search content/i) const input = within(modal).getByPlaceholderText(/Search this file/i)
fireEvent.change(input, { target: { value: 'balance' } }) fireEvent.change(input, { target: { value: 'balance' } })
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0)) await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
}) })

View File

@@ -34,9 +34,9 @@ async function openChanged(): Promise<HTMLElement> {
} }
describe('Editor view modes', () => { describe('Editor view modes', () => {
it('Updated mode is an editable buffer; Original is read-only', async () => { it('Actual mode is an editable buffer; Original is read-only', async () => {
const c = await openChanged() const c = await openChanged()
fireEvent.click(find(c, '.seg button', 'Updated')!) fireEvent.click(find(c, '.seg button', 'Actual')!)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy()) await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
fireEvent.click(find(c, '.seg button', 'Original')!) fireEvent.click(find(c, '.seg button', 'Original')!)

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

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git' import { simpleGit } from 'simple-git'
import { buildTreeFromPaths, readAll, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service' import { buildTreeFromPaths, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
let dir = '' let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) }) afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -33,6 +33,51 @@ describe('readTree', () => {
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md')) expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin')) 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', () => { describe('readAll', () => {
@@ -47,7 +92,7 @@ describe('readAll', () => {
expect(files['src/a.ts']).toContain('export const a') expect(files['src/a.ts']).toContain('export const a')
}) })
it('honors .gitignore in a repo (files.followGitignore default on)', async () => { it('shows .gitignored files in a repo (files.followGitignore default off)', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-')) dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await simpleGit(dir).init() await simpleGit(dir).init()
await writeFile(join(dir, '.gitignore'), 'secret.txt\n') await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
@@ -55,7 +100,7 @@ describe('readAll', () => {
await writeFile(join(dir, 'keep.txt'), 'ok') await writeFile(join(dir, 'keep.txt'), 'ok')
const keys = Object.keys(await readAll(dir)) const keys = Object.keys(await readAll(dir))
expect(keys).toContain('keep.txt') expect(keys).toContain('keep.txt')
expect(keys).not.toContain('secret.txt') expect(keys).toContain('secret.txt') // gitignore not followed by default → shown
}) })
}) })
@@ -67,6 +112,15 @@ describe('buildTreeFromPaths', () => {
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts']) 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') 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', () => { describe('read/write round-trip', () => {
@@ -76,3 +130,23 @@ describe('read/write round-trip', () => {
expect(await readProjectFile(dir, 'note.txt')).toBe('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()
})
})

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

View File

@@ -9,17 +9,33 @@ import { classify, discard, load, stage } from '../src/main/git-service'
describe('classify', () => { describe('classify', () => {
it('reads the index code as staged, working code as unstaged', () => { 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: true }])
expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false }) expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }])
expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true }) expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }])
expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true }) expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }])
expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true }) expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }])
}) })
it('treats untracked as a new (A) unstaged file', () => { it('treats untracked as a new (A) unstaged file', () => {
expect(classify('?', '?')).toEqual({ letter: 'A', staged: false }) expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }])
}) })
it('maps unmerged (U) to modified', () => { it('splits a staged-then-edited file into two rows', () => {
expect(classify('U', 'U').letter).toBe('M') 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 }])
}) })
}) })
@@ -77,6 +93,51 @@ describe('load (integration against a temp repo)', () => {
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true) 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 () => { it('discard reverts a modified tracked file to HEAD', async () => {
dir = await repo() dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n') await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')

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')
})
})