Compare commits

..

20 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
46 changed files with 4110 additions and 462 deletions

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.

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

@@ -14,6 +14,9 @@ asarUnpack:
- '**/node_modules/node-pty/**' - '**/node_modules/node-pty/**'
- '**/node_modules/@vscode/ripgrep/**' - '**/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:
@@ -22,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 },
} }

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, rm, 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) {
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
} else {
let dir = dirs.get(curPath) let dir = dirs.get(curPath)
if (!dir) { if (!dir) {
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] } dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
dirs.set(curPath, dir) dirs.set(curPath, dir)
parent.children!.push(dir) parent.children!.push(dir)
} }
parent = dir parent = dir
parentPath = curPath 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,65 @@ 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. */ /** Delete a project file or folder (relative path). Stays inside the project root. */
export async function deleteProjectFile(root: string, rel: string): Promise<void> { export async function deleteProjectFile(root: string, rel: string): Promise<void> {
const target = join(root, rel) const target = join(root, rel)

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 { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project' import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { deleteProjectFile, 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, getRecent, getThemeCss, resolveConfig, setRecent } from './config' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service' import { listFiles, searchContent } from './search-service'
import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger'
const isDev = !!process.env['ELECTRON_RENDERER_URL'] const isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
// Before anything else can fail: start the crash reporter, open the log file and
// hook uncaughtException / render-process-gone / preload-error. Everything below
// (including a throw at module load) is logged from here on.
initDiagnostics(isDev)
const WATCH_IGNORE = new Set([ const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
@@ -18,12 +28,40 @@ const WATCH_IGNORE = new Set([
let watcher: FSWatcher | null = null let watcher: FSWatcher | null = null
let configWatcher: FSWatcher | null = null let configWatcher: FSWatcher | null = null
let gitWatcher: FSWatcher | null = null
let watchTimer: ReturnType<typeof setTimeout> | null = null let watchTimer: ReturnType<typeof setTimeout> | null = null
let gitTimer: ReturnType<typeof setTimeout> | null = null
function broadcast(channel: string): void { function broadcast(channel: string): void {
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel) for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel)
} }
/**
* Each Helder window is one project, and one project owns global main-process
* state (root + FS/git/config watchers + PTYs). To run several projects beside
* each other we therefore spawn a *separate, detached* Helder process per window
* rather than opening a second BrowserWindow in this process.
*
* Spawning `process.execPath` directly also sidesteps macOS LaunchServices, which
* otherwise just re-activates the running instance when the bundle is launched
* again from Finder/Dock. `HELDER_PROJECT` points the child at a project (the
* child's `resolveInitialRoot` reads it); with no path it lands on the launcher.
*/
function spawnInstance(projectPath?: string): void {
const env = { ...process.env }
if (projectPath && safeIsDir(projectPath)) env.HELDER_PROJECT = resolve(projectPath)
else delete env.HELDER_PROJECT
// Packaged: execPath IS the app, no app path needed. Dev: replay our own argv
// (the electron-vite main entry) so the child loads the same app.
const args = app.isPackaged ? [] : process.argv.slice(1)
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env })
child.unref()
}
function safeIsDir(p: string): boolean {
try { return statSync(p).isDirectory() } catch { return false }
}
function startConfigWatcher(): void { function startConfigWatcher(): void {
if (configWatcher) { configWatcher.close(); configWatcher = null } if (configWatcher) { configWatcher.close(); configWatcher = null }
const root = getRoot() const root = getRoot()
@@ -34,6 +72,47 @@ 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> {
@@ -42,6 +121,8 @@ async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
await resolveConfig(next) 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,50 +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() }
}) })
ipcMain.handle('projects:recent', () => getRecentProjects()) handle('projects:recent', () => getRecentProjects())
ipcMain.handle('project:openPath', async (_e, path: string) => { handle('project:openPath', async (_e, path: string) => {
setRoot(path) setRoot(path)
const r = getRoot() const r = getRoot()
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher() } if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
syncWindowTitle()
broadcast('project:changed') broadcast('project:changed')
return { root: getRoot(), name: getName() } return { root: getRoot(), name: getName() }
}) })
ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null }) handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} }) handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' }) handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) }) handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) }) handle('fs:imageDataUrl', (_e, rel: string) => { const r = getRoot(); return r ? readImageDataUrl(r, rel) : '' })
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(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', () => { const r = getRoot(); return r ? load(r) : null }) handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, 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('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] }) handle('config:get', () => getConfig())
ipcMain.handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) }) handle('config:theme', () => getThemeCss())
ipcMain.handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] }) handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] }) handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => { 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',
@@ -143,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,
@@ -151,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: {
@@ -161,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' }
@@ -176,26 +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()
const initialRoot = getRoot() const initialRoot = getRoot()
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
if (initialRoot) { if (initialRoot) {
await resolveConfig(initialRoot) await resolveConfig(initialRoot)
await addRecentProject(initialRoot) await addRecentProject(initialRoot)
startWatcher() startWatcher()
startConfigWatcher() 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

@@ -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,10 +20,14 @@ 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
/** /**
@@ -30,11 +35,28 @@ let seq = 0
* `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes * `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 * 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. * 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 } { function ptyEnv(): { [key: string]: string } {
const env = { ...process.env } as { [key: string]: string } const env = { ...process.env } as { [key: string]: string }
delete env.npm_config_prefix delete env.npm_config_prefix
delete env.npm_config_globalconfig 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 return env
} }
@@ -50,7 +72,10 @@ 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 shell = defaultShell() const shell = defaultShell()
const ai = getConfig().ai const ai = getConfig().ai
@@ -61,7 +86,7 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
// echoed command cluttering the pane; claude takes over a clean terminal. // echoed command cluttering the pane; claude takes over a clean terminal.
const args = launchAgent ? ['-i', '-c', ai.command] : [] const args = launchAgent ? ['-i', '-c', ai.command] : []
const proc = pty.spawn(shell, args, { const proc = pty.spawn(shell, args, {
name: 'xterm-color', name: 'xterm-256color',
cols: cols || 80, cols: cols || 80,
rows: rows || 24, rows: rows || 24,
cwd, cwd,
@@ -69,9 +94,21 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
}) })
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 })
})
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there). // Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') { if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
@@ -90,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

@@ -94,12 +94,14 @@ export async function searchContent(root: string, query: string): Promise<Conten
} }
/** 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 async 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 const rgPath = await rgPathPromise
if (!rgPath) return [] if (!rgPath) return []
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root]) const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
let buf = '' let buf = ''
const out: string[] = [] const out: string[] = []
let done = false let done = false

View File

@@ -10,6 +10,7 @@ const api = {
clipboard: { clipboard: {
writeText: (text: string) => clipboard.writeText(text), writeText: (text: string) => clipboard.writeText(text),
readText: (): string => clipboard.readText(),
}, },
project: { project: {
@@ -21,21 +22,30 @@ const api = {
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), 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: { shell: {
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) }, 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),
}, },
@@ -78,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()
@@ -91,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) {

View File

@@ -5,11 +5,13 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor' import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor' import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals' import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays' import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
import { ProjectLauncher } from './launcher' import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays' import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types' import type { DiffSide, FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project' import { useProject, useProjectActions } from './project'
import { HL } from './highlight'
import { rlog } from './log'
import { loadJson, loadNum, saveJson, saveNum } from './persist' import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set<string>() const NO_COMMITTED = new Set<string>()
@@ -36,7 +38,7 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} /> return <div className={'splitter' + (orientation === 'h' ? ' h' : '') + (drag ? ' drag' : '')} onMouseDown={down} />
} }
function RightColumn({ width, onFocus }: { width: number; onFocus: () => void }): React.ReactElement { function RightColumn({ width, active, onFocus }: { width: number; active: boolean; onFocus: () => void }): React.ReactElement {
const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52)) const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac]) useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
@@ -45,7 +47,7 @@ function RightColumn({ width, onFocus }: { width: number; onFocus: () => void })
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h))) setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
} }
return ( return (
<div className="col right-col" style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}> <div className={'col right-col' + (active ? ' panel-active' : '')} style={{ width, flex: '0 0 ' + width + 'px' }} onMouseDownCapture={onFocus}>
<div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <div ref={ref} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}> <div style={{ flex: '0 0 ' + (topFrac * 100) + '%', minHeight: 0, display: 'flex' }}>
<Terminal kind="agent" /> <Terminal kind="agent" />
@@ -65,13 +67,6 @@ function ancestors(path: string): string[] {
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/')) for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'))
return out return out
} }
function initialOpenDirs(node: FileNode, set: Set<string>): Set<string> {
if (node.type === 'dir') {
if (node.open && node.path) set.add(node.path)
;(node.children || []).forEach((c) => initialOpenDirs(c, set))
}
return set
}
export function App(): React.ReactElement { export function App(): React.ReactElement {
const proj = useProject() const proj = useProject()
@@ -82,11 +77,19 @@ export function App(): React.ReactElement {
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({}) const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
// Which git row opened each tab. Only Diff and Split follow it: Original and
// Actual always show HEAD and the file on disk.
const [tabSide, setTabSide] = useState<Record<string, DiffSide>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set()) const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null) const [cursor, setCursor] = useState<Cursor | null>(null)
const [selection, setSelection] = useState<Selection | null>(null) const [selection, setSelection] = useState<Selection | null>(null)
const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | null>(null) const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | 'projects' | 'notes' | null>(null)
const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection
// Project scratch note (.notes.txt). savedNote tracks what is on disk, so a
// blur with no edits does not rewrite the file (and wake the fs watcher).
const [note, setNote] = useState('')
const noteRef = useRef(note); noteRef.current = note
const savedNote = useRef('')
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator. // Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
const [history, setHistory] = useState<string[]>([]) const [history, setHistory] = useState<string[]>([])
const [histInitSel, setHistInitSel] = useState(0) const [histInitSel, setHistInitSel] = useState(0)
@@ -94,16 +97,22 @@ export function App(): React.ReactElement {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null) const [splitFor, setSplitFor] = useState<string | null>(null)
const [commitMsg, setCommitMsg] = useState('') const [commitMsg, setCommitMsg] = useState('')
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null) const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null) const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null)
// Brief full-screen "branch - repository" flash whenever the window gains focus // Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows). // (handy when juggling several project windows).
const [showFlash, setShowFlash] = useState(false) const [showFlash, setShowFlash] = useState(false)
// Fullscreen on macOS hides the traffic lights, so the title bar reclaims the
// space they reserve. Main tells us; the browser preview simply stays false.
const [fullscreen, setFullscreen] = useState(false)
// Editable buffers: path → current text (absent = clean, showing on-disk content). // Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({}) const [buffers, setBuffers] = useState<Record<string, string>>({})
const buffersRef = useRef(buffers); buffersRef.current = buffers const buffersRef = useRef(buffers); buffersRef.current = buffers
const projRef = useRef(proj); projRef.current = proj const projRef = useRef(proj); projRef.current = proj
const activeRef = useRef(active); activeRef.current = active
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function diskText(path: string): string { return proj.files[path] ?? '' } function diskText(path: string): string { return proj.files[path] ?? '' }
@@ -111,7 +120,27 @@ export function App(): React.ReactElement {
function isDirty(path: string): boolean { return buffers[path] != null && buffers[path] !== diskText(path) } function isDirty(path: string): boolean { return buffers[path] != null && buffers[path] !== diskText(path) }
function writeToDisk(path: string, text: string): void { function writeToDisk(path: string, text: string): void {
if (window.helder) window.helder.fs.write(path, text).catch(() => toast('Save failed', path)) if (!window.helder) return
// A save flips the file's working-tree state (clean → modified, etc.), so
// refresh the git column directly the moment the write lands rather than
// waiting on the FS watcher's debounce. Crucially also re-sync our on-disk
// snapshot (proj.files = diskText) to what's now on disk: without it the save
// guard compares against stale content, so an undo back to the original
// followed by ⌘S is skipped — the file stays modified on disk and the app
// keeps showing it as "changed". Git status then decides the state.
window.helder.fs.write(path, text)
.then(() => {
actions.reloadFile(path).then((disk) => {
// Buffer is redundant once it's on disk — but only drop it if nothing
// was typed during the write (autosave debounce); never clobber newer edits.
setBuffers((b) => { if (b[path] !== disk) return b; const n = { ...b }; delete n[path]; return n })
}).catch(() => {})
actions.refreshGit()
})
// A failed save is the one error here that can lose work, so it gets the
// reason logged (permissions, read-only volume, file vanished) — the toast
// alone can't say why.
.catch((e) => { rlog.error('save', 'write failed', e, { path, bytes: text.length }); toast('Save failed', path) })
} }
function saveActive(): void { function saveActive(): void {
if (!active) return if (!active) return
@@ -129,6 +158,16 @@ export function App(): React.ReactElement {
saveTimer.current = setTimeout(() => writeToDisk(path, text), 600) saveTimer.current = setTimeout(() => writeToDisk(path, text), 600)
} }
} }
// Re-read a file from disk and drop any in-memory buffer for it, so the
// editable "Updated"/code view always shows on-disk truth. Called whenever a
// file is opened or the view mode changes — the agent (or an external tool)
// may have rewritten the file since it was last loaded. On-disk content wins:
// an unsaved local edit is replaced by what's actually on disk.
function reloadFromDisk(path: string): void {
actions.reloadFile(path).then(() => {
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
}).catch(() => {})
}
function doDiscard(path: string): void { function doDiscard(path: string): void {
if (proj.config.git.confirmDiscard && if (proj.config.git.confirmDiscard &&
!window.confirm(`Discard changes to ${path}?\nThis reverts the file to the last commit and cannot be undone.`)) return !window.confirm(`Discard changes to ${path}?\nThis reverts the file to the last commit and cannot be undone.`)) return
@@ -138,19 +177,25 @@ export function App(): React.ReactElement {
} }
// Proportional columns, two regimes (Editor C is the flex remainder): // Proportional columns, two regimes (Editor C is the flex remainder):
// ≥ 1650px (roomy) → Git 10% · Explorer 10% · Editor 40% · Right 40% // ≥ 1600px (roomy) → Git 10% · Explorer 15% · Editor 37% · Right 38%
// (no focus-driven changes — everything fits) // (no focus-driven changes — everything fits)
// < 1650px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus: // < 1600px (tight) → Git 15% · Explorer 15%, Editor/Right react to focus:
// default Editor 40% / Right 30% // default Editor 40% / Right 30%
// focus editor → Editor 50% / Right 20% // focus editor → Editor 50% / Right 20%
// focus agent/terminal → Editor 20% / Right 50% // focus agent/terminal → Editor 20% / Right 50%
// Re-applied on resize + focus change; dragging still works in between. // Re-applied on resize + focus change; dragging still works in between.
const FOCUS_RESIZE_BELOW = 1650 const FOCUS_RESIZE_BELOW = 1600
const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default') const [focusZone, setFocusZone] = useState<'default' | 'editor' | 'terminal'>('default')
// Which column currently has focus — drives the active-panel tint and keyboard
// navigation (arrows move a row cursor in Git/Explorer, ⌘→ opens its menu).
const [activePanel, setActivePanel] = useState<'git' | 'tree' | 'editor' | 'terminal' | null>(null)
const [gitSel, setGitSel] = useState(0)
const [treeSel, setTreeSel] = useState(0)
// Auto panel management: re-fit columns on resize/focus. Manually dragging a // Auto panel management: re-fit columns on resize/focus. Manually dragging a
// splitter switches it off (the user took control); the title-bar toggle // splitter switches it off (the user took control); the title-bar toggle
// turns it back on (and immediately re-fits). // turns it back on (and immediately re-fits).
const [autoResize, setAutoResize] = useState(true) const [autoResize, setAutoResize] = useState(true)
const [showHidden, setShowHidden] = useState(false)
const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15)) const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15))
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15)) const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3)) const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
@@ -160,8 +205,8 @@ export function App(): React.ReactElement {
const w = window.innerWidth const w = window.innerWidth
if (w >= FOCUS_RESIZE_BELOW) { if (w >= FOCUS_RESIZE_BELOW) {
setGitW(Math.round(w * 0.1)) setGitW(Math.round(w * 0.1))
setTreeW(Math.round(w * 0.1)) setTreeW(Math.round(w * 0.15))
setRightW(Math.round(w * 0.4)) setRightW(Math.round(w * 0.38))
} else { } else {
setGitW(Math.round(w * 0.15)) setGitW(Math.round(w * 0.15))
setTreeW(Math.round(w * 0.15)) setTreeW(Math.round(w * 0.15))
@@ -174,25 +219,98 @@ export function App(): React.ReactElement {
return () => window.removeEventListener('resize', apply) return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize]) }, [focusZone, autoResize])
// Flash the branch · repository banner for ~2s each time the window gains focus. // Flat, render-order lists of the rows in Git (A) and Explorer (B) — the targets
// for arrow-key navigation. Git: staged group then changes group. Tree: the
// currently-visible nodes (honouring expansion + the hidden-files toggle).
const gitNav = useMemo(() => {
const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path))
const stagedRows = visible.filter((c) => c.staged)
const changeRows = visible.filter((c) => !c.staged)
// Rows, not paths: one file can sit in both groups (staged, then edited again).
return [...stagedRows, ...changeRows].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
}, [proj.changes])
const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = []
const walk = (node: FileNode): void => {
if (node.type === 'dir') {
const isOpen = openDirs.has(node.path) || node.path === ''
if (node.path !== '') out.push({ path: node.path, type: 'dir' })
if (isOpen) (node.children || []).filter((c) => showHidden || !c.name.startsWith('.')).forEach(walk)
} else {
out.push({ path: node.path, type: 'file' })
}
}
if (proj.tree) walk(proj.tree)
return out
}, [proj.tree, openDirs, showHidden])
const gitSelRow = gitNav[gitSel] ?? null
const gitSelPath = gitSelRow?.path ?? null
const treeSelItem = treeNav[treeSel] ?? null
// Clicking a row moves the keyboard cursor onto it. Without this the cursor
// stays at index 0, so the top row of the list keeps its highlight next to
// whichever row the click actually selected.
const syncGitSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.git-row') as HTMLElement | null
const id = row?.dataset.rowId
if (!id) return
const i = gitNav.findIndex((r) => r.id === id)
if (i >= 0) setGitSel(i)
}
const syncTreeSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.tree-row') as HTMLElement | null
const path = row?.dataset.rowPath
if (path == null) return
const i = treeNav.findIndex((r) => r.path === path)
if (i >= 0) setTreeSel(i)
}
// Keep the row cursors in range as the lists shrink/grow.
useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length])
useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length])
// Scroll the selected row into view when navigating with the keyboard.
useEffect(() => {
if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' })
}, [gitSel, activePanel])
// Flash the branch · repository banner for ~2s each time the window *regains*
// focus. We gate on a prior blur so the banner never shows on startup (or on
// any focus event fired during launch) — only on a genuine "welcome back".
useEffect(() => { useEffect(() => {
let timer: ReturnType<typeof setTimeout> let timer: ReturnType<typeof setTimeout>
let wasBlurred = false
function flash(): void { function flash(): void {
if (!wasBlurred) return // first-time-after-open (or launch focus): skip
wasBlurred = false
setShowFlash(true) setShowFlash(true)
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000) timer = setTimeout(() => setShowFlash(false), 2000)
} }
if (document.hasFocus()) flash() function onBlur(): void { wasBlurred = true }
window.addEventListener('focus', flash) window.addEventListener('focus', flash)
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) } window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('focus', flash)
window.removeEventListener('blur', onBlur)
clearTimeout(timer)
}
}, []) }, [])
// Seed explorer expansion from the tree's `open` flags once per opened project. // Follow the window's fullscreen state (see the title-bar padding in styles.css).
useEffect(() => {
const subscribe = window.helder?.onFullscreen
if (!subscribe) return
return subscribe((on) => setFullscreen(on))
}, [])
// Open/reopen a project with a fully collapsed tree: seed the expansion set
// empty once per opened project (the root row is always shown regardless).
// A refresh keeps the user's expansion since seededRoot guards on proj.root.
const seededRoot = useRef<string | null | undefined>(undefined) const seededRoot = useRef<string | null | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (proj.tree && seededRoot.current !== proj.root) { if (proj.tree && seededRoot.current !== proj.root) {
seededRoot.current = proj.root seededRoot.current = proj.root
setOpenDirs(initialOpenDirs(proj.tree, new Set())) setOpenDirs(new Set())
} }
}, [proj.tree, proj.root]) }, [proj.tree, proj.root])
@@ -205,15 +323,47 @@ export function App(): React.ReactElement {
if (!proj.ready || sessionRoot.current === proj.root) return if (!proj.ready || sessionRoot.current === proj.root) return
sessionRoot.current = proj.root sessionRoot.current = proj.root
recentReady.current = false recentReady.current = false
setHistory([]); setActive(null); setTabMode({}) setHistory([]); setActive(null); setTabMode({}); setTabSide({})
if (proj.config.session.restoreOnLaunch) { if (proj.config.session.restoreOnLaunch) {
const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null) const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode>; tabSide?: Record<string, DiffSide> } | null>(`helder.session:${proj.root}`, null)
if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) } if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}); setTabSide(saved.tabSide ?? {}) }
} }
const bridge = window.helder const bridge = window.helder
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true }) if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch]) }, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
// Write the note to <project>/.notes.txt. Skipped when nothing changed, so a
// plain alt-tab does not touch the file or wake the project watcher.
const saveNote = useCallback((): void => {
const bridge = window.helder
if (!bridge || !projRef.current.root) return
const text = noteRef.current
if (text === savedNote.current) return
savedNote.current = text
bridge.notes.write(text).catch((e) => rlog.error('notes', 'save failed', e))
}, [])
// The note is saved when the window loses focus. beforeunload covers the other
// way out — closing the window or quitting, which never fires a blur.
useEffect(() => {
window.addEventListener('blur', saveNote)
window.addEventListener('beforeunload', saveNote)
return () => {
window.removeEventListener('blur', saveNote)
window.removeEventListener('beforeunload', saveNote)
}
}, [saveNote])
// Load this project's note. Each window holds one project, so this runs once
// per project change.
useEffect(() => {
const bridge = window.helder
if (!bridge || !proj.root) { setNote(''); savedNote.current = ''; return }
bridge.notes.read()
.then((t) => { setNote(t); savedNote.current = t })
.catch((e) => rlog.error('notes', 'load failed', e))
}, [proj.root])
// Persist the history to .helder/recent.json (newest first, capped to 100 in main), // Persist the history to .helder/recent.json (newest first, capped to 100 in main),
// but only once it's been loaded for this project (so we never clobber it with []). // but only once it's been loaded for this project (so we never clobber it with []).
useEffect(() => { useEffect(() => {
@@ -224,8 +374,8 @@ export function App(): React.ReactElement {
useEffect(() => { useEffect(() => {
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
saveJson(`helder.session:${proj.root}`, { active, tabMode }) saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide })
}, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch]) }, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch])
function toast(title: string, ref?: string): void { function toast(title: string, ref?: string): void {
const id = lid() const id = lid()
@@ -246,7 +396,8 @@ export function App(): React.ReactElement {
const toggleDir = useCallback((p: string) => { const toggleDir = useCallback((p: string) => {
setOpenDirs((s) => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n }) setOpenDirs((s) => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n })
}, []) actions.refreshDir(p) // re-read the folder on every open/close so its children stay fresh
}, [actions])
function reveal(path: string): void { function reveal(path: string): void {
setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n }) setOpenDirs((s) => { const n = new Set(s); ancestors(path).forEach((a) => n.add(a)); return n })
@@ -261,6 +412,13 @@ export function App(): React.ReactElement {
setCommitMsg('') setCommitMsg('')
} }
function push(): void {
toast('Pushing…')
actions.push()
.then((r) => toast(r.ok ? 'Pushed' : 'Push failed', r.message))
.catch((e) => toast('Push failed', String(e?.message ?? e)))
}
function revealInFinder(path: string): void { function revealInFinder(path: string): void {
window.helder?.shell.reveal(path) window.helder?.shell.reveal(path)
} }
@@ -268,7 +426,7 @@ export function App(): React.ReactElement {
async function deleteEntry(path: string, isDir: boolean): Promise<void> { async function deleteEntry(path: string, isDir: boolean): Promise<void> {
const bridge = window.helder const bridge = window.helder
if (bridge) { if (bridge) {
try { await bridge.fs.delete(path) } catch { toast('Delete failed', path); return } try { await bridge.fs.delete(path) } catch (e) { rlog.error('fs', 'delete failed', e, { path, isDir }); toast('Delete failed', path); return }
} }
const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/')) const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/'))
setHistory((h) => h.filter((p) => !inside(p))) setHistory((h) => h.filter((p) => !inside(p)))
@@ -281,6 +439,34 @@ export function App(): React.ReactElement {
actions.refresh() actions.refresh()
toast(isDir ? 'Deleted folder' : 'Deleted file', path) toast(isDir ? 'Deleted folder' : 'Deleted file', path)
} }
// Create a new empty file inside `dir` (project-relative folder, '' = root),
// then open it in a tab so the user can start typing right away.
async function createFile(dir: string, name: string): Promise<void> {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '')
const bridge = window.helder
if (bridge) {
try { await bridge.fs.create(rel) } catch (e) { rlog.error('fs', 'create file failed', e, { path: rel }); toast('Create failed', rel); return }
}
if (dir) setOpenDirs((d) => { const n = new Set(d); n.add(dir); return n })
actions.refresh()
toast('Created file', rel)
openFile(rel)
}
// Create a new empty folder inside `dir` (project-relative, '' = root). The
// empty folder shows up in the tree at once (listEmptyDirs); expand the parent
// and the new folder so it's visible right away.
async function createFolder(dir: string, name: string): Promise<void> {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '').replace(/\/+$/, '')
const bridge = window.helder
if (bridge) {
try { await bridge.fs.mkdir(rel) } catch (e) { rlog.error('fs', 'create folder failed', e, { path: rel }); toast('Create failed', rel); return }
}
setOpenDirs((d) => { const n = new Set(d); if (dir) n.add(dir); n.add(rel); return n })
actions.refresh()
toast('Created folder', rel)
}
function askDelete(path: string, isDir: boolean): void { function askDelete(path: string, isDir: boolean): void {
setConfirm({ setConfirm({
title: isDir ? 'Delete folder?' : 'Delete file?', title: isDir ? 'Delete folder?' : 'Delete file?',
@@ -300,16 +486,24 @@ export function App(): React.ReactElement {
actions.unstage(p) actions.unstage(p)
} }
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void { function openFile(path: string, opts: { diff?: boolean; line?: number; side?: DiffSide } = {}): void {
const changed = !!proj.diffs[path] const changed = !!proj.diffs[path]
setFocusZone('editor') setFocusZone('editor')
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100)) setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
actions.ensureFile(path) reloadFromDisk(path)
setActive(path) setActive(path)
// Git rows open the diff; explorer / recent-files open the updated view. // Git rows open the diff; explorer / recent-files open the updated view.
// Unchanged files only have the plain editable "code" view. // Unchanged files only have the plain editable "code" view.
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code' const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
setTabMode((m) => ({ ...m, [path]: openMode })) setTabMode((m) => ({ ...m, [path]: openMode }))
// Remember which git row this came from, so Diff/Split show that half. An
// explorer click carries no side and falls back to the whole file.
setTabSide((m) => {
const n = { ...m }
if (opts.side) n[path] = opts.side
else delete n[path]
return n
})
reveal(path) reveal(path)
if (opts.line) { if (opts.line) {
// The updated/code views render in the CodeEditor (a textarea over a <pre>), // The updated/code views render in the CodeEditor (a textarea over a <pre>),
@@ -357,56 +551,123 @@ export function App(): React.ReactElement {
} }
// ---- context menus ---- // ---- context menus ----
function openMenu(e: React.MouseEvent, target: ContextTarget): void { // Naming contract: every "Pass on …" action opens the input popup (so the user
e.preventDefault(); e.stopPropagation() // can attach a note), and its "Copy …" twin sits directly below it. Pass first,
const sparkSend = (ref: string): Menu['items'][number] => ({ icon: Icon.spark(), label: 'Send reference to agent', onClick: () => { window.dispatchEvent(new CustomEvent('agentPaste', { detail: ref })); toast('Passed to agent', ref) } }) // Copy under it.
function buildMenu(target: ContextTarget, x: number, y: number): { items: Menu['items']; note: string; path?: string } {
const spark = Icon.spark({ style: { color: 'var(--ren)' } })
const openPass = (ref: string, code?: string): void => setPassPopup({ x, y, ref, code })
if (target.kind === 'editor') { if (target.kind === 'editor') {
const ref = target.sel ? `${target.path}:${target.sel.start}-${target.sel.end}` : `${target.path}:${target.line}` const hasCode = !!(target.code && target.code.length)
const mx = e.clientX, my = e.clientY const ref = target.sel
setMenu({ ? (target.sel.start === target.sel.end ? `${target.path}:${target.sel.start}` : `${target.path}:${target.sel.start}-${target.sel.end}`)
x: mx, y: my, note: ref, : `${target.path}:${target.line ?? 1}`
return {
note: ref,
items: [ items: [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, { primary: true, icon: spark, label: hasCode ? 'Pass on selection' : 'Pass on reference', onClick: () => openPass(ref, hasCode ? target.code : undefined) },
{ icon: Icon.spark(), label: 'Pass on to Agent', onClick: () => setPassPopup({ x: mx, y: my, ref }) }, { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
], ],
}) }
} else { }
const isDir = target.kind === 'dir' const isDir = target.kind === 'dir'
const ref = isDir ? target.path + '/' : target.path const ref = isDir ? target.path + '/' : target.path
const name = target.path.split('/').pop() as string const name = target.path.split('/').pop() as string
const items: Menu['items'] = [ const items: Menu['items'] = [
{ primary: true, icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) }, { primary: true, icon: spark, label: 'Pass on reference', onClick: () => openPass(ref) },
sparkSend(ref), { icon: Icon.copy(), label: 'Copy reference', onClick: () => copyText(ref) },
{ icon: Icon.copy(), label: isDir ? 'Copy folder path' : 'Copy file name', onClick: () => copyText(isDir ? target.path : name, 'Copied') },
] ]
// A folder's reference already is its path; only files get the basename twin.
if (!isDir) { if (!isDir) {
items.push({ sep: true }) items.push({ icon: spark, label: 'Pass on file name', onClick: () => openPass(name) })
if (target.kind === 'git') { items.push({ icon: Icon.copy({ style: { color: 'var(--mod)' } }), label: 'Copy file name', onClick: () => copyText(name, 'Copied') })
const isStaged = proj.staged.has(target.path)
items.push(isStaged
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff(), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
} }
items.push({ icon: Icon.file(), label: 'Open file', onClick: () => openFile(target.path) }) if (isDir) {
items.push({ sep: true })
items.push({ icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x, y, dir: target.path }) })
items.push({ icon: Icon.folder({ style: { color: 'var(--add)' } }), label: 'New folder', onClick: () => setNewFolderPopup({ x, y, dir: target.path }) })
}
if (target.kind === 'git') {
items.push({ sep: true })
// The row carries its own flag: a file can have a staged and an unstaged row.
const isStaged = target.staged ?? proj.staged.has(target.path)
items.push(isStaged
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
} }
// Show in Finder + delete — for explorer files and folders (not git rows). // Show in Finder + delete — for explorer files and folders (not git rows).
if (isDir || target.kind === 'file') { if (isDir || target.kind === 'file') {
items.push({ sep: true }) items.push({ sep: true })
items.push({ icon: Icon.finder(), label: 'Show in Finder', onClick: () => revealInFinder(target.path) }) items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash(), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) }) items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
} }
setMenu({ x: e.clientX, y: e.clientY, note: ref, items }) return { items, note: ref, path: target.path }
} }
function openMenuAt(x: number, y: number, target: ContextTarget): void {
const { items, note, path } = buildMenu(target, x, y)
setMenu({ x, y, note, path, items })
}
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
e.preventDefault(); e.stopPropagation()
// Editor menus open right at the cursor. Tree/git row menus instead anchor to
// the right edge of the whole column (matching the ⌘→ keyboard menu) so the
// opaque menu floats entirely beside the list — it must never reach back over
// the rows, or it hides the file directly under the one you right-clicked.
if (target.kind === 'editor') { openMenuAt(e.clientX, e.clientY, target); return }
openMenuAt(rowAnchorX(e.currentTarget as HTMLElement), e.clientY, target)
}
// Left edge for a tree/git row menu: the right edge of the row's column, so the
// menu sits clear of every row in that column (robust to horizontal scroll and
// narrow columns, where the row's own right edge can fall under the rows).
function rowAnchorX(row: HTMLElement): number {
const col = row.closest('.col') as HTMLElement | null
return (col ?? row).getBoundingClientRect().right
}
// ⌘→ inside Git/Explorer: open the selected row's menu, anchored to its row.
function openPanelMenu(): boolean {
if (activePanel === 'git' && gitSelPath) {
const row = document.querySelector('.git-row.kbd') as HTMLElement | null
const r = row?.getBoundingClientRect()
openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: gitSelRow?.staged })
return true
}
if (activePanel === 'tree' && treeSelItem) {
const row = document.querySelector('.tree-row.kbd') as HTMLElement | null
const r = row?.getBoundingClientRect()
openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: treeSelItem.path, kind: treeSelItem.type })
return true
}
return false
}
// ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
function openPanelSelection(): boolean {
if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { diff: true, side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
if (activePanel === 'tree' && treeSelItem) {
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
else openFile(treeSelItem.path)
return true
}
return false
} }
// ⌘M cycles the active changed file through the four view modes. // ⌘M cycles the active file through whatever views it supports: a changed file
// runs Updated → Original → Diff → Split (+ Preview for markdown); an unchanged
// markdown file toggles Code ↔ Preview. Plain unchanged files have one view, so
// there's nothing to cycle.
function cycleMode(): void { function cycleMode(): void {
if (!active || !proj.diffs[active]) return if (!active) return
const order: (Mode | 'split')[] = ['updated', 'original', 'diff', 'split'] reloadFromDisk(active)
const cur = splitFor === active ? 'split' : (tabMode[active] || defaultMode) const md = HL.langFor(active) === 'markdown'
const next = order[(order.indexOf(cur) + 1) % order.length] const hasDiff = !!proj.diffs[active]
const order: (Mode | 'split')[] = hasDiff
? ['updated', 'original', 'diff', 'split', ...(md ? (['preview'] as const) : [])]
: md ? ['code', 'preview'] : ['code']
if (order.length < 2) return
const cur = splitFor === active ? 'split' : (tabMode[active] || (hasDiff ? defaultMode : 'code'))
const idx = order.indexOf(cur)
const next = order[(idx < 0 ? 0 : idx + 1) % order.length]
if (next === 'split') setSplitFor(active) if (next === 'split') setSplitFor(active)
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) } else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
} }
@@ -421,7 +682,20 @@ export function App(): React.ReactElement {
: (window.getSelection()?.toString() ?? '') : (window.getSelection()?.toString() ?? '')
return raw.split('\n')[0].trim() return raw.split('\n')[0].trim()
} }
// ⌘→ with a selection: open Pass-on-to-Agent for the selected line range. // Reconstruct the source for a line range from the rendered Diff/Original view
// (its rows carry data-line + a .ln-code span).
function codeFromDom(start: number, end: number): string {
const parts: string[] = []
document.querySelectorAll<HTMLElement>('.editor .ln-row').forEach((row) => {
const ln = row.dataset.line
if (!ln) return
const n = +ln
if (n >= start && n <= end) parts.push(row.querySelector('.ln-code')?.textContent ?? '')
})
return parts.join('\n')
}
// ⌘→ with a selection: open Pass-on-to-Agent for the selected line range,
// carrying the selected code so it's passed as a fenced block.
// Returns true when a selection was found (so we can swallow the key). // Returns true when a selection was found (so we can swallow the key).
function passSelection(): boolean { function passSelection(): boolean {
if (!active) return false if (!active) return false
@@ -432,24 +706,38 @@ export function App(): React.ReactElement {
const s = v.slice(0, ae.selectionStart).split('\n').length const s = v.slice(0, ae.selectionStart).split('\n').length
const en = v.slice(0, ae.selectionEnd).split('\n').length const en = v.slice(0, ae.selectionEnd).split('\n').length
const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}` const ref = s === en ? `${active}:${s}` : `${active}:${s}-${en}`
const code = v.slice(ae.selectionStart, ae.selectionEnd)
const r = ae.getBoundingClientRect() const r = ae.getBoundingClientRect()
setPassPopup({ x: r.left + 60, y: r.top + 70, ref }) setPassPopup({ x: r.left + 60, y: r.top + 70, ref, code })
return true return true
} }
// Diff / Original (PaneView) — line range tracked in `selection` state. // Diff / Original (PaneView) — line range tracked in `selection` state.
if (selection && selection.path === active && selection.start !== selection.end) { if (selection && selection.path === active && selection.start !== selection.end) {
const ref = `${active}:${selection.start}-${selection.end}` const ref = `${active}:${selection.start}-${selection.end}`
const code = codeFromDom(selection.start, selection.end)
const dom = window.getSelection() const dom = window.getSelection()
let x = window.innerWidth / 2, y = 150 let x = window.innerWidth / 2, y = 150
if (dom && dom.rangeCount && !dom.isCollapsed) { if (dom && dom.rangeCount && !dom.isCollapsed) {
const rr = dom.getRangeAt(0).getBoundingClientRect() const rr = dom.getRangeAt(0).getBoundingClientRect()
if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 } if (rr.width || rr.height) { x = rr.left; y = rr.bottom + 6 }
} }
setPassPopup({ x, y, ref }) setPassPopup({ x, y, ref, code })
return true return true
} }
return false return false
} }
// ⌘P with the note open hands the whole note to the agent. Same route as the
// editor's Pass on to Agent: bracketed paste, so nothing is submitted. The note
// is saved and closed, so you see the text land in the agent composer.
function passNote(): boolean {
const text = noteRef.current.trim()
if (!text) return false
window.dispatchEvent(new CustomEvent('agentPaste', { detail: text }))
setOverlay(null)
saveNote()
toast('Note passed to agent', '.notes.txt')
return true
}
function hasSelection(): boolean { function hasSelection(): boolean {
if ((window.getSelection()?.toString() ?? '') !== '') return true if ((window.getSelection()?.toString() ?? '') !== '') return true
const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
@@ -464,14 +752,36 @@ export function App(): React.ReactElement {
const meta = e.metaKey || e.ctrlKey const meta = e.metaKey || e.ctrlKey
const ae = document.activeElement as HTMLElement | null const ae = document.activeElement as HTMLElement | null
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
// The history navigator owns the keyboard while open (it listens in capture phase). // The history / project navigators own the keyboard while open (capture phase).
if (overlay === 'history') return if (overlay === 'history' || overlay === 'projects') return
// An open context menu owns the keyboard (arrows / ↵ / esc handled there).
if (menu) return
const inPanel = activePanel === 'git' || activePanel === 'tree'
// Arrow up/down move the row cursor in the focused Git/Explorer panel.
if (!meta && !inField && inPanel && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
e.preventDefault()
const len = activePanel === 'git' ? gitNav.length : treeNav.length
if (len === 0) return
const set = activePanel === 'git' ? setGitSel : setTreeSel
set((s) => e.key === 'ArrowDown' ? Math.min(s + 1, len - 1) : Math.max(s - 1, 0))
return
}
// ↵ opens the selected row (git → diff, file → open, folder → toggle).
if (!meta && !inField && inPanel && e.key === 'Enter') {
if (openPanelSelection()) { e.preventDefault(); return }
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (splitFor) setSplitFor(null) if (splitFor) setSplitFor(null)
// Closing the note saves it there and then, rather than leaving the text
// to wait for the next blur.
else if (overlay === 'notes') { setOverlay(null); saveNote() }
else if (overlay) setOverlay(null) else if (overlay) setOverlay(null)
else setMenu(null) else setMenu(null)
return return
} }
// ⌘P with the note open passes the note text to the agent. This runs before
// the global ⌘P (push), so the note wins while its overlay is up.
if (overlay === 'notes' && meta && e.key.toLowerCase() === 'p') { e.preventDefault(); passNote(); return }
// Search / help modals own the keyboard while open (they handle their own keys). // Search / help modals own the keyboard while open (they handle their own keys).
if (overlay) return if (overlay) return
@@ -481,21 +791,32 @@ export function App(): React.ReactElement {
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0) setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
setOverlay('history') setOverlay('history')
} }
// ⌘F and ⌘P both open the unified search (it covers file names too), seeded // ⇧⌘O opens the recent-project history picker (⌘O — opening a new folder —
// with the current selection when there is one. // is the native File-menu accelerator, so the renderer is free to own ⇧⌘O).
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') } else if (meta && e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); setOverlay('projects') }
// ⌘F opens the unified search (it covers file names too), seeded with the
// current selection when there is one.
else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
// ⌘P pushes the current branch to its remote.
else if (meta && e.key.toLowerCase() === 'p') { e.preventDefault(); push() }
// ⌘N opens the project note.
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() } else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) } else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
// ⌘D deletes the current file (with confirmation). // ⌘D deletes the current file (with confirmation).
else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) } else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() } else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
// ⌘→ with a text selection passes that selection to the agent (else native nav). // ⌘→ in Git/Explorer opens the selected row's menu; in the editor it passes
else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() } // the current text selection to the agent (else native nav).
else if (meta && e.key === 'ArrowRight') {
if (inPanel && !inField) { if (openPanelMenu()) e.preventDefault() }
else if (passSelection()) e.preventDefault()
}
// ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself). // ⌘↵ commits the staged files (unless the commit box has focus — it handles ⇧/⌘↵ itself).
else if (meta && e.key === 'Enter') { else if (meta && e.key === 'Enter') {
if (ae && ae.classList.contains('commit-input')) return if (ae && ae.classList.contains('commit-input')) return
e.preventDefault() e.preventDefault()
if (commitMsg.trim() && proj.changes.some((c) => proj.staged.has(c.path))) commit() if (commitMsg.trim() && proj.changes.some((c) => c.staged)) commit()
} }
// ⌘C focuses the commit message (but let native copy run when there's a selection). // ⌘C focuses the commit message (but let native copy run when there's a selection).
else if (meta && e.key.toLowerCase() === 'c') { else if (meta && e.key.toLowerCase() === 'c') {
@@ -507,27 +828,86 @@ export function App(): React.ReactElement {
if (inField) return if (inField) return
e.preventDefault(); setAutoResize((v) => !v) e.preventDefault(); setAutoResize((v) => !v)
} }
// ⌘. toggles hidden files. Match on e.code so it fires regardless of layout.
else if (meta && e.code === 'Period') {
e.preventDefault(); setShowHidden((v) => !v)
}
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm]) }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
// ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
// left columns: git status (A) + the file tree (B) via a full project reload,
// and the open file in the viewer (C) re-read from disk. The viewer reload
// drops any in-memory buffer so the editable view shows on-disk truth — an
// explicit refresh is exactly when the user wants whatever the agent just
// wrote, the same "on-disk wins" rule used on file open / mode change.
// A refresh reads from disk, so nothing may visibly change — the columns then
// look inert and the keypress feels lost. Flash A/B/C light grey for ~250 ms so
// ⌘R always reads as "that landed". Two class names (a/b) alternate because a
// CSS animation only restarts when the animation-name changes: on a second ⌘R
// inside the window, re-adding the same class would replay nothing.
const [flashTick, setFlashTick] = useState(0)
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const flashClass = flashTick === 0 ? '' : (flashTick % 2 ? ' refresh-flash-a' : ' refresh-flash-b')
useEffect(() => () => { if (flashTimer.current) clearTimeout(flashTimer.current) }, [])
useEffect(() => {
if (!window.helder) return
return window.helder.onRefresh(() => {
actions.refresh()
const path = activeRef.current
if (path) reloadFromDisk(path)
setFlashTick((n) => n + 1)
if (flashTimer.current) clearTimeout(flashTimer.current)
flashTimer.current = setTimeout(() => setFlashTick(0), 400)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Re-read git status the moment the Git column (Col A) gains focus, so it
// reflects on-disk truth whenever the user turns to it — e.g. after the agent
// rewrote files while focus was elsewhere. Git-only fast path (no tree/index
// re-walk); the FS watcher stays the backstop for everything else.
useEffect(() => {
if (activePanel === 'git') actions.refreshGit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePanel])
// Poll git status on an interval as a backstop for the FS watcher: an external
// tool (the agent, the git CLI) rewriting files *should* fire the watcher's
// project:changed, but a missed filesystem event would otherwise leave the Git
// column stale until the next manual ⌘R. Interval is config-driven
// (git.refreshInterval ms; 0 disables). Fast path — git status only.
useEffect(() => {
if (!window.helder) return
const ms = proj.config.git.refreshInterval
if (!ms || ms <= 0) return
const id = setInterval(() => actions.refreshGit(), ms)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proj.config.git.refreshInterval])
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code') const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
// Which half the open tab shows. Also lights up the matching git row.
const activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : [] const crumb = active ? active.split('/') : []
// No project yet (launched via Spotlight / bare) → show the project launcher. // No project yet (launched via Spotlight / bare) → show the project launcher.
if (proj.ready && !proj.root) { if (proj.ready && !proj.root) {
return <ProjectLauncher onOpenNew={() => actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} /> return <ProjectLauncher recents={proj.recents} onOpenNew={() => actions.openFolder()} onOpenPath={(p) => actions.openProjectPath(p)} />
} }
return ( return (
<div className="app"> <div className="app">
{/* title bar */} {/* title bar */}
<div className="titlebar"> <div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div> <div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title">{Icon.spark({ style: { color: 'var(--accent)' } })}<b>Helder</b><span style={{ color: 'var(--fg-3)' }}></span> <div className="tb-title">
<span style={{ color: 'var(--fg-2)', cursor: 'pointer' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</span> <b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
<span style={{ color: 'var(--fg-3)' }}>{proj.branch}</span>
</div> </div>
{active && ( {active && (
<div className="tb-crumb"> <div className="tb-crumb">
@@ -535,16 +915,23 @@ export function App(): React.ReactElement {
{isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>} {isDirty(active) && <span className="tb-dirty" title="Unsaved changes"></span>}
</div> </div>
)} )}
<div className="tb-repo" title={proj.branch + ' - ' + proj.name}>
{Icon.branch()}<span className="tb-repo-branch">{proj.branch}</span>
<span className="tb-repo-sep">-</span><span className="tb-repo-name">{proj.name}</span>
</div>
<div className="tb-spacer" /> <div className="tb-spacer" />
<div className="tb-actions"> <div className="tb-actions">
<button className="tb-btn" onClick={() => { setSearchInit(''); setOverlay('search') }}>{Icon.search()} Search <kbd>F</kbd></button> <button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
title="Search contents & names">
{Icon.search()} Search <kbd>F</kbd>
</button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)} <button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}> title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span> {Icon.layout()} Auto-fit <kbd>A</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
{Icon.eye()} Hidden <kbd>.</kbd>
</button>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}
title="Project note (.notes.txt) — kept next to this project">
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd>
</button> </button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button> <button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div> </div>
@@ -564,27 +951,31 @@ export function App(): React.ReactElement {
{/* workbench */} {/* workbench */}
<div className="workbench"> <div className="workbench">
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}> <div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED} onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}>
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg} commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} showDir={gitW > 300} /> onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> <Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
<div className="col" style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}> <div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}>
{proj.tree ? ( {proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile} <FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} changeMap={changeMap} committed={NO_COMMITTED} /> onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
kbdPath={activePanel === 'tree' ? (treeSelItem?.path ?? null) : null} changeMap={changeMap} committed={NO_COMMITTED} showHidden={showHidden} />
) : ( ) : (
<div className="tree-body" /> <div className="tree-body" />
)} )}
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} /> <Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className="col editor-col" onMouseDownCapture={() => setFocusZone('editor')}> <div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} <Editor active={active} mode={mode} side={activeSide}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null) } }} setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
onContext={openMenu} onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active} onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection} cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
@@ -597,22 +988,30 @@ export function App(): React.ReactElement {
}) }} /> }) }} />
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */} {/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} onFocus={() => setFocusZone('terminal')} />} {proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
</div> </div>
{/* overlays */} {/* overlays */}
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />} {splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} {passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
onConfirm={(text) => { onConfirm={(payload) => {
const line = (text && text.trim() ? text.trim() + ' ' : '') + passPopup.ref window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
window.dispatchEvent(new CustomEvent('agentPaste', { detail: line }))
setPassPopup(null) setPassPopup(null)
toast('Passed to agent', passPopup.ref) toast('Passed to agent', passPopup.ref)
}} }}
onCancel={() => setPassPopup(null)} />} onCancel={() => setPassPopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />} {newFilePopup && <NamePopup x={newFilePopup.x} y={newFilePopup.y} dir={newFilePopup.dir}
onConfirm={(name) => { createFile(newFilePopup.dir, name); setNewFilePopup(null) }}
onCancel={() => setNewFilePopup(null)} />}
{newFolderPopup && <NamePopup x={newFolderPopup.x} y={newFolderPopup.y} dir={newFolderPopup.dir} kind="folder"
onConfirm={(name) => { createFolder(newFolderPopup.dir, name); setNewFolderPopup(null) }}
onCancel={() => setNewFolderPopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />} {overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />} {overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />} {confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />} {menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} /> <Toasts toasts={toasts} />

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,18 +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>), 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>), 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>), 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>), 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 => (
@@ -51,32 +56,40 @@ 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, showDir, 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 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 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>
@@ -91,10 +104,9 @@ function GitRow({ c, staged, activePath, showDir, onOpen, onContext, onToggleSta
) )
} }
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, showDir }: { 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
@@ -103,14 +115,18 @@ 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 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
@@ -121,6 +137,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
placeholder="Shift+Enter to commit" placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(e.target.value)} onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} /> onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
</div> </div>
<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} showDir={showDir} <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} showDir={showDir} <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 || [])
.filter((c) => showHidden || !c.name.startsWith('.'))
.map((c) => (
<TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1} <TreeNode key={c.path} node={c} depth={node.path === '' ? 0 : depth + 1}
openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen} openDirs={openDirs} toggleDir={toggleDir} onOpen={onOpen}
onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} /> 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,21 +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="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: four view modes (Original / Updated / Diff / Split) + line selection */ /* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, 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)
@@ -46,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
@@ -53,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)
} }
@@ -69,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} />
@@ -79,6 +125,42 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
) )
} }
/* Rendered-markdown preview: read-only, derived from the live buffer text. */
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
const html = useMemo(() => renderMarkdown(text), [text])
return (
<div className="md-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
<div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />
</div>
)
}
/* Image preview: fetches the file as a data: URL from main (the renderer can't
* read the filesystem) and shows it centred on the editor surface. Read-only. */
function ImageView({ path, onContext }: { path: string; onContext: OnContext }): React.ReactElement {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
setSrc(''); setFailed(false)
const bridge = window.helder
if (!bridge) { setFailed(true); return }
bridge.fs.imageDataUrl(path)
.then((url) => { if (alive) { if (url) setSrc(url); else setFailed(true) } })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [path])
return (
<div className="img-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
{src
? <img className="img-view-img" src={src} alt={path.split('/').pop()} />
: failed
? <div className="empty-ed"><div className="big" style={{ color: 'var(--fg-3)' }}>Cant preview this image</div></div>
: null}
</div>
)
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */ /* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: { function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string cacheKey: string
@@ -133,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()
@@ -141,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
@@ -180,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 }
@@ -190,15 +279,22 @@ 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: 'updated', label: 'Updated' }, * it's markdown. Unchanged files only have the plain editable "Code" view; a
{ id: 'original', label: 'Original' }, * 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({ active, mode, setMode, 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 }: {
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
onContext: OnContext onContext: OnContext
onSplit: (path: string) => void onSplit: (path: string) => void
@@ -212,22 +308,46 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const tab = active ? { path: active } : null 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 (
@@ -236,39 +356,60 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
<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
state (changed → diff views, markdown → Preview, else just Code). */}
<div className="diff-bar"> <div className="diff-bar">
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span> {change ? (
{change.add > 0 && <span className="a">+{change.add}</span>} <Fragment>
{change.del > 0 && <span className="d">{change.del}</span>} <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>
))} ))}
{hasDiff && (
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side"> <button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg> <svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg>
Split Split
</button> </button>
</div> )}
</div> </div>
)} )}
{emptyUpdated ? ( </div>
{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} />
)} )}
@@ -279,20 +420,24 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
} }
/* 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
@@ -314,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>

View File

@@ -11,7 +11,7 @@ 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 }>
@@ -20,19 +20,28 @@ interface HelderBridge {
} }
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> delete: (path: string) => Promise<void>
create: (path: string) => Promise<void>
mkdir: (path: string) => Promise<void>
} }
shell: { shell: {
reveal: (path: string) => void 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: {
@@ -59,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>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => location.reload()} style={{ <button onClick={() => location.reload()} style={{
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600, background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
padding: '7px 14px', cursor: 'pointer', fontSize: 12, padding: '7px 14px', cursor: 'pointer', fontSize: 12,
}}>Reload</button> }}>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

@@ -4,14 +4,13 @@
* selection, ↵ opens it — same model as the recent-files navigator. */ * selection, ↵ opens it — same model as the recent-files navigator. */
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Icon } from './components' import { Icon } from './components'
import type { RecentProject } from './project'
interface RecentProject { path: string; name: string } export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
recents: RecentProject[]
export function ProjectLauncher({ onOpenNew, onOpenPath }: {
onOpenNew: () => void onOpenNew: () => void
onOpenPath: (path: string) => void onOpenPath: (path: string) => void
}): React.ReactElement { }): React.ReactElement {
const [recents, setRecents] = useState<RecentProject[]>([])
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const selRef = useRef(sel); selRef.current = sel const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null)
@@ -19,11 +18,6 @@ export function ProjectLauncher({ onOpenNew, onOpenPath }: {
// rows = [new project, ...recents]; total selectable count // rows = [new project, ...recents]; total selectable count
const count = recents.length + 1 const count = recents.length + 1
useEffect(() => {
const bridge = window.helder
if (bridge) bridge.project.recent().then(setRecents).catch(() => setRecents([]))
}, [])
function activate(i: number): void { function activate(i: number): void {
if (i <= 0) onOpenNew() if (i <= 0) onOpenNew()
else if (recents[i - 1]) onOpenPath(recents[i - 1].path) else if (recents[i - 1]) onOpenPath(recents[i - 1].path)

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,20 +37,35 @@ 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({ initialQuery, 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 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(() => initialQuery ?? '') const [q, setQ] = useState(() => initialQuery ?? '')
const [sel, setSel] = useState(0) const [sel, setSel] = useState(0)
const [fileSel, setFileSel] = useState(0) const [fileSel, setFileSel] = useState(0)
const [col, setCol] = useState<'content' | 'files'>('content') // active result column (⌘← / ⌘→) 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) const rightRef = useRef<HTMLDivElement>(null)
@@ -75,12 +103,32 @@ export function SearchModal({ initialQuery, 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 }
@@ -89,18 +137,19 @@ export function SearchModal({ initialQuery, 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
const fileCount = Math.min(files.length, 40) const fileCount = Math.min(files.length, 40)
useEffect(() => { setSel(0); setFileSel(0) }, [q]) 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' })
@@ -109,21 +158,34 @@ export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet
const el = rightRef.current && rightRef.current.querySelector('.fres.sel') const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
if (el) el.scrollIntoView({ block: 'nearest' }) if (el) el.scrollIntoView({ block: 'nearest' })
}, [fileSel]) }, [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.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') { e.preventDefault(); setCol('content'); return } if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') { e.preventDefault(); setCol('files'); return } e.preventDefault()
const i = Math.max(0, cols.indexOf(col))
const ni = e.key === 'ArrowLeft' ? Math.max(0, i - 1) : Math.min(cols.length - 1, i + 1)
setCol(cols[ni])
return
}
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1)) 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 setSel((s) => Math.min(s + 1, flat.length - 1))
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0)) 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 setSel((s) => Math.max(s - 1, 0))
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
if (col === 'files') { 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() } 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 { } else {
@@ -147,15 +209,35 @@ export function SearchModal({ initialQuery, 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">
{hasInFile && (
<div className={'sc-infile' + (col === 'infile' ? ' active' : '')} ref={inFileRef}>
<div className="sc-head">
<FileIcon path={activePath as string} />
<span className="scf-name" title={activePath as string}>{(activePath as string).split('/').pop()}</span>
{inFile.length > 0 && <span className="sc-ct">{inFile.length}</span>}
<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-left' + (col === 'content' ? ' active' : '')} ref={leftRef}>
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>} <kbd className="col-kbd"></kbd></div> <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} />
@@ -266,23 +348,88 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
) )
} }
/* 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). */ /* Keyboard-shortcuts reference (opened from the title-bar ? button). */
const SHORTCUTS: { keys: string[]; label: string }[] = [ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' }, { keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
{ keys: ['⌘', '↑'], label: 'Navigate a list up' }, { keys: ['⌘', '↑'], label: 'Navigate a list up' },
{ keys: ['⌘', '↓'], label: 'Navigate a list down' }, { keys: ['⌘', '↓'], label: 'Navigate a list down' },
{ keys: ['↵'], label: 'Open the selected list item' }, { keys: ['↵'], label: 'Open the selected list item' },
{ keys: ['⌘', '←'], label: 'Search: focus the content results' }, { keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
{ keys: ['⌘', '→'], label: 'Search: focus the file-name results' }, { keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
{ keys: ['', ''], label: 'Pass the selected text to the agent' }, { 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: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' }, { keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' }, { keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'S'], label: 'Save the current file' }, { keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' }, { keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' }, { 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 project folder' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' }, { keys: ['Esc'], label: 'Close an overlay / split view' },
] ]
@@ -293,7 +440,7 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
<div className="pi"> <div className="pi">
{Icon.help({ style: { color: 'var(--fg-3)' } })} {Icon.help({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Keyboard shortcuts</span> <span className="hist-title">Keyboard shortcuts</span>
<span className="mode-chip"><kbd>esc</kbd></span> <kbd>esc</kbd>
</div> </div>
<div className="help-list"> <div className="help-list">
{SHORTCUTS.map((s, i) => ( {SHORTCUTS.map((s, i) => (
@@ -308,6 +455,44 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
) )
} }
/**
* 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 /* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
* it owns the keyboard while open. */ * it owns the keyboard while open. */
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: { export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
@@ -344,21 +529,52 @@ export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onC
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>
@@ -383,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('')
@@ -403,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>
@@ -411,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. */
@@ -37,29 +56,58 @@ export interface ProjectActions {
openFolder: () => void openFolder: () => void
openProjectPath: (path: string) => 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 {
// non-null root so browser-preview shows the workbench, not the launcher // non-null root so browser-preview shows the workbench, not the launcher
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs, tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, 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 }>({
@@ -67,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
} }
@@ -79,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
// it. Lazily-loaded files (ensureFile) win over the bulk read.
bridge.fs.files().then((files) => {
if (seq !== loadSeq.current) return
setData((d) => ({ ...d, files: { ...(files || {}), ...d.files } }))
}).catch(() => {})
} }
}
setData({ // Fast path for the three git mutations (stage / unstage / commit) + discard:
name: cur.name, root: cur.root, branch: git ? git.branch : '—', // re-read ONLY git status and patch the git fields, skipping the full
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true, // 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> {
@@ -117,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(() => {}) })
@@ -128,40 +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: () => {}, 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(() => {}) }, 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: () => {
@@ -170,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,18 +1,18 @@
/* ============ 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:#f19f3f; --accent:#f19f3f;
@@ -55,6 +55,12 @@ body {
#root { height:100vh; } #root { height:100vh; }
::selection { background:rgba(241,159,63,0.30); } ::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; }
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; } ::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
@@ -106,21 +112,40 @@ body {
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; } .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 .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); } .tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); } .tb-toggle.on:hover { background:var(--accent-soft); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
.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; }
@@ -143,6 +168,8 @@ 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); }
.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; }
.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 { 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: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; }
@@ -160,9 +187,12 @@ 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; }
@@ -180,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); }
@@ -199,7 +230,17 @@ body {
/* ============ editor ============ */ /* ============ editor ============ */
.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); }
@@ -211,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; }
@@ -232,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); }
@@ -245,7 +333,6 @@ 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); }
@@ -317,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; }
@@ -339,7 +430,6 @@ body {
.history-modal .pi svg { flex:0 0 auto; } .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 .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; } .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; }
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; } .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 { 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.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
@@ -350,7 +440,7 @@ body {
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); 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; }
@@ -373,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; }
@@ -382,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); }
@@ -407,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;
@@ -430,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,
@@ -458,21 +556,33 @@ body {
.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; } .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-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-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-row kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; flex:0 0 auto; }
.lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); } .lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); }
.lp-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; }
/* ============ keyboard-shortcuts (help) modal ============ */ /* ============ 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 { 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 { 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 .pi svg { flex:0 0 auto; }
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; } .help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.help-modal .mode-chip { flex:0 0 auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
.help-list { max-height:62vh; overflow:auto; padding:8px 6px; } .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 { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
.help-row:hover { background:var(--hover); } .help-row:hover { background:var(--hover); }
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; } .help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
.help-keys kbd { font-family:var(--mono); font-size:11px; color:var(--fg-1); background:var(--bg-1); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; min-width:20px; text-align:center; } .help-keys kbd { min-width:20px; text-align:center; }
.help-label { font-size:12.5px; color:var(--fg-2); } .help-label { font-size:12.5px; color:var(--fg-2); }
/* title-bar icon-only button (help ?) */ /* title-bar icon-only button (help ?) */
@@ -485,7 +595,6 @@ body {
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; } .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 { 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-btn:hover { background:var(--hover); color:var(--fg-0); }
.cf-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; } .cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
.cf-yes:hover { background:#f6b35f; color:#201608; } .cf-yes:hover { background:#f6b35f; color:#201608; }
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); } .cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
@@ -494,7 +603,8 @@ body {
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); } .cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
/* search: active result column + file-name selection */ /* search: active result column + file-name selection */
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; } .sc-head .col-kbd { margin-left:auto; opacity:.55; }
.sc-left.active .sc-head, .sc-right.active .sc-head { color:var(--accent); } .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 { color:var(--accent); border-color:var(--accent-line); opacity:1; } .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); } .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 termRef = useRef<XTerm | null>(null)
const [, setLive] = useState(kind === 'agent') 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,14 +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 }}
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
onContextMenu={onContextMenu}>
<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 },

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

View File

@@ -33,6 +33,18 @@ 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 () => {
@@ -62,6 +74,7 @@ describe('App (mock data, jsdom)', () => {
it('makes an edited buffer dirty (breadcrumb 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')
@@ -88,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')
})
})

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