Compare commits
31 Commits
3f5078841d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e126182ae6 | |||
| daf8945da7 | |||
| 03e16d49a1 | |||
| d3bcdb74c2 | |||
| 6c3a021bb9 | |||
| 43131915c0 | |||
| 73bfd2b86a | |||
| ab6f09bde2 | |||
| 7f5d1a0d03 | |||
| fb4018336f | |||
| 6114ce440d | |||
| 5e5fc53dde | |||
| 0a90ab822f | |||
| 6beef86506 | |||
| 513af0e164 | |||
| e55f4e714e | |||
| 42defcc7cd | |||
| 16296a27da | |||
| 4b4676a673 | |||
| 29c90725ae | |||
| 7c69853e85 | |||
| d5dbf7187d | |||
| 87fcf9bf93 | |||
| 8fc6478bb1 | |||
| 7bac3fe7c5 | |||
| 6a940b9b7a | |||
| 0879f51f48 | |||
| bd466e84a8 | |||
| 299ae17d80 | |||
| 6abee5f8b6 | |||
| 66248c4736 |
20
.github/workflows/ci.yml
vendored
Normal file
20
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
|
||||
2
.helder/.gitignore
vendored
Normal file
2
.helder/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Helder — local, machine-specific state (do not commit).
|
||||
*
|
||||
27
.helder/config.default.json
Normal file
27
.helder/config.default.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"ai": {
|
||||
"command": "claude",
|
||||
"autoLaunch": true
|
||||
},
|
||||
"editor": {
|
||||
"autoSave": false,
|
||||
"tabSize": 4
|
||||
},
|
||||
"git": {
|
||||
"confirmDiscard": true,
|
||||
"confirmStage": false,
|
||||
"confirmUnstage": false,
|
||||
"defaultDiffMode": "diff",
|
||||
"refreshInterval": 10000
|
||||
},
|
||||
"files": {
|
||||
"exclude": [],
|
||||
"followGitignore": false
|
||||
},
|
||||
"terminal": {
|
||||
"shell": null
|
||||
},
|
||||
"session": {
|
||||
"restoreOnLaunch": true
|
||||
}
|
||||
}
|
||||
14
.helder/theme.css
Normal file
14
.helder/theme.css
Normal file
@@ -0,0 +1,14 @@
|
||||
/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
* This file is created once and never overwritten; edit it freely.
|
||||
* The code font and font size live here (not in config.json). Uncomment and
|
||||
* tweak any variable below; you can also override any --token from the built-in
|
||||
* theme (see the design tokens in the app's styles). */
|
||||
:root {
|
||||
/* Code surfaces (editor + terminals) */
|
||||
/* --code-font: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; */
|
||||
/* --code-size: 13px; */ /* editor font size */
|
||||
/* --term-size: 12.5px; */ /* terminal font size */
|
||||
|
||||
/* Example accent override: */
|
||||
/* --accent: #4d8dff; */
|
||||
}
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 140,
|
||||
"tabWidth": 2
|
||||
}
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -6,8 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. JetBrains Mono is bundled locally via `@fontsource/jetbrains-mono`; Prism is wired with the correct `markup-templating`→`php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
|
||||
|
||||
**Phase 2 (real integrations) — essentially complete.** All over IPC through the preload bridge (`src/preload/index.ts`):
|
||||
- **Filesystem** — tree, in-memory content index, `chokidar` watch (`src/main/fs-service.ts`).
|
||||
**Phase 2 (real integrations) — complete; DESIGN.md fully implemented.** The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — `⌘P` aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (`npm test`, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (`src/preload/index.ts`):
|
||||
- **Filesystem** — tree + in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). The tree/index/search share one source of truth: `rg --files` (honors gitignore + `files.exclude`, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable.
|
||||
- **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
|
||||
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
|
||||
|
||||
@@ -45,10 +45,13 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
|
||||
|
||||
- **Renderer never touches the filesystem, git, or PTYs directly.** All FS (`fs` + `chokidar`), git (`git` / `simple-git`), search (`rg` + fuzzy), terminals (`node-pty` + `xterm.js`), and clipboard go through the main process via IPC / the preload bridge. The prototype keeps all state in the top `App` component; in the real app, lift FS/git/terminal state into main and stream over IPC.
|
||||
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
|
||||
- **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync.
|
||||
- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here).
|
||||
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
|
||||
- **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.
|
||||
- **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)
|
||||
|
||||
@@ -72,6 +75,17 @@ Settings are project-scoped, living in a `.helder/` folder in the opened project
|
||||
- Effective value = `config.json` if present, else `config.default.json`, merged key by key.
|
||||
- `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files.
|
||||
|
||||
## Logging & crash diagnostics
|
||||
|
||||
One file, `~/Library/Logs/Helder/helder.log` (rotates at 2 MB, keeps 3), written **synchronously** so a line survives the process dying right after it. Reachable from **Help → Open Log** and from the crash panel's *Open Log* button. Main and renderer both write to it, so a failure reads as one chronological story.
|
||||
|
||||
- `src/main/logger.ts` — the sink. Deliberately imports NO electron so it stays unit-testable (`test/logger.test.ts`); `initLogger({dir})` is handed the path by the caller. Every process logs its pid, since sibling project windows share the file.
|
||||
- `src/main/diagnostics.ts` — `initDiagnostics()` runs **before** `app.whenReady()` (crashReporter must start early; `app.setName` must precede `app.getPath('logs')` or logs land in `~/Library/Logs/Electron`). Hooks `uncaughtException`, `unhandledRejection`, `render-process-gone` (the blank-window crash), `child-process-gone`, `preload-error`, `unresponsive`, and renderer console warnings/errors. Native minidumps (node-pty can segfault) go to `app.getPath('crashDumps')`, local only — nothing is uploaded.
|
||||
- `src/main/index.ts` — the `handle()` / `on()` wrappers around `ipcMain`: every IPC failure is logged with channel + args, then **rethrown** so renderer behaviour is unchanged. Calls over 1 s log a `slow` warning. Note both wrappers must call `ipcMain.handle`/`ipcMain.on` — a rename that rewrites those lines makes the wrappers infinitely recursive and silently registers **no handlers at all** (every IPC then fails with "No handler registered").
|
||||
- `src/renderer/src/log.ts` — `rlog` + `installErrorLogging()` (window `error`, `unhandledrejection`). Called from `main.tsx` before first render. `ErrorBoundary` logs the component stack, which exists nowhere else.
|
||||
|
||||
Keep warnings honest: an expected event must not log as WARN (see `killing` in `pty-service.ts` — deliberate kills log INFO). A log full of false alarms is a log nobody reads.
|
||||
|
||||
## Design tokens
|
||||
|
||||
Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a` → `--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle.
|
||||
@@ -92,5 +106,10 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design
|
||||
- `npm run build` — type-stripped production build into `out/` (`electron-vite build`). A frontend change is not done until this succeeds.
|
||||
- `npm run preview` / `npm start` — run the built app (`electron-vite preview`).
|
||||
- `npm run typecheck` — `tsc --noEmit` over the renderer (`tsconfig.web.json`) and main/preload (`tsconfig.node.json`). The build itself uses esbuild and does NOT type-check, so run this separately to catch type errors.
|
||||
- `npm test` — vitest suite in `test/` (pure logic + node-side services: diff, fuzzy, highlight, config, fs, git). `npm run test:watch` for watch mode.
|
||||
- `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied).
|
||||
- `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged.
|
||||
|
||||
No test/lint runner is wired up yet — add and document them here when introduced.
|
||||
**The mac build must be ad-hoc signed — `build/adhoc-sign.cjs` (the `afterPack` hook) does this.** `mac.identity: null` skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports `Identifier=Electron`, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with *"Malware Blocked and Moved to Trash"*. A real ad-hoc signature over the whole bundle (with `build/entitlements.mac.plist` for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with `xattr -dr com.apple.quarantine /Applications/Helder.app` or ship a Developer ID build.
|
||||
|
||||
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.
|
||||
|
||||
54
README.md
54
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Helder is an Electron app that puts code review, git, and a live Claude Code agent side by side in one dense, IDE-style window. It opens one project per window, is dark-only by design (no light mode, no theme toggle), and is built around a single idea: make it effortless to point an AI agent at exactly the code you're looking at.
|
||||
|
||||
> **Status: greenfield.** This repository currently contains the design handoff only — no application code yet. The first task is to scaffold the Electron app and port the prototype. See [Getting started](#getting-started).
|
||||
> **Status: working build.** The Electron app is scaffolded and everything above is implemented against the real filesystem, git, terminals, ripgrep search, and the `.helder/` config system. The editor is writable (save · autosave · discard). See [Getting started](#getting-started).
|
||||
|
||||
---
|
||||
|
||||
@@ -48,19 +48,30 @@ Right-click in the editor to copy a project-relative `path:line` reference (e.g.
|
||||
|
||||
## Getting started
|
||||
|
||||
> No build tooling exists yet. This section will be filled in once the Electron + Vite toolchain is scaffolded.
|
||||
Requires Node 18+ and a recent `git` on your `PATH`.
|
||||
|
||||
Planned implementation order:
|
||||
```bash
|
||||
npm install # also rebuilds node-pty for Electron (postinstall)
|
||||
npm run dev # launch the app with hot reload
|
||||
```
|
||||
|
||||
1. Electron shell + frameless dark window; port design tokens to CSS variables; bundle JetBrains Mono.
|
||||
2. Static layout: four resizable columns + title/status bars.
|
||||
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
|
||||
4. Git panel from `git status` → staging + commit → the four diff modes + Split.
|
||||
5. Search (ripgrep + fuzzy).
|
||||
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
|
||||
7. Copy reference + Pass-on-to-Agent.
|
||||
Helder opens **one project per window** — by default the current working directory. Open a different folder by clicking the project name in the title bar, or launch with `HELDER_PROJECT=/path/to/repo npm run dev`. The agent pane auto-runs the `claude` CLI, so it must be on your `PATH`.
|
||||
|
||||
To preview the design prototype now, open `design_handoff_helder_workbench/design/Helder - AI Code Workbench.html` in a browser — it's a clickable React-via-Babel mock with sample data.
|
||||
### Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `npm run dev` | Launch in Electron with HMR |
|
||||
| `npm run build` | Production build into `out/` |
|
||||
| `npm start` | Run the built app |
|
||||
| `npm test` | Run the vitest suite |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run typecheck` | `tsc --noEmit` (renderer + main/preload) |
|
||||
| `npm run pack` | Unpacked app into `dist/` (electron-builder) |
|
||||
| `npm run dist` | Distributable (`.dmg` / `.zip` / etc.) |
|
||||
| `npm run rebuild` | Re-rebuild `node-pty` for Electron if a terminal shows "PTY unavailable" |
|
||||
|
||||
To preview the original design prototype, open `design_handoff_helder_workbench/design/Helder - AI Code Workbench.html` in a browser — a clickable React-via-Babel mock with sample data.
|
||||
|
||||
---
|
||||
|
||||
@@ -79,17 +90,20 @@ An effective setting is the value from `config.json` if present, otherwise from
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
DESIGN.md Functional/UX spec — every panel, state, and interaction
|
||||
CLAUDE.md Guidance for Claude Code working in this repo
|
||||
design_handoff_helder_workbench/
|
||||
README.md Technical handoff — structure, design tokens, integration mechanics
|
||||
design/
|
||||
Helder - AI Code Workbench.html Clickable prototype (open in a browser)
|
||||
styles.css Canonical design tokens (the :root block)
|
||||
src/*.jsx Prototype components (reference only — replace mock data)
|
||||
src/
|
||||
main/ Electron main process: window + IPC + services
|
||||
(fs-service, git-service, pty-service, search-service, config, project)
|
||||
preload/ contextIsolation bridge — the only renderer↔OS surface (window.helder)
|
||||
renderer/ React UI: App, editor (4 diff modes + writable buffer), terminals (xterm),
|
||||
overlays (search/menu/toasts), project store, diff/highlight/fuzzy helpers
|
||||
test/ vitest suite — diff, fuzzy, highlight, config, fs, git
|
||||
electron.vite.config.ts electron-builder.yml eslint.config.js vitest.config.ts
|
||||
DESIGN.md Functional/UX spec — every panel, state, interaction
|
||||
CLAUDE.md Guidance + current architecture for Claude Code
|
||||
design_handoff_helder_workbench/ Original design handoff + clickable prototype
|
||||
```
|
||||
|
||||
The two handoff documents are the source of truth: **`DESIGN.md`** for *what the app does*, **`design_handoff_helder_workbench/README.md`** for *how to build it*. The prototype is a visual reference — do not ship it as-is.
|
||||
`DESIGN.md` and `design_handoff_helder_workbench/README.md` remain the design source of truth; the prototype is a visual reference, not shipped.
|
||||
|
||||
---
|
||||
|
||||
|
||||
44
build/adhoc-sign.cjs
Normal file
44
build/adhoc-sign.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
// Ad-hoc sign the macOS bundle after electron-builder packs it.
|
||||
//
|
||||
// Why this exists: `mac.identity: null` tells electron-builder to skip signing
|
||||
// altogether. The .app then keeps only the linker signature that Apple put on
|
||||
// the prebuilt Electron binary. That signature says `Identifier=Electron`,
|
||||
// seals no resources, and does not bind our Info.plist. macOS reads a bundle
|
||||
// like that as tampered-with and shows "Malware Blocked and Moved to Trash".
|
||||
//
|
||||
// A real ad-hoc signature over the whole bundle fixes it. The app stays
|
||||
// unsigned in the Developer ID sense (no notarization, so a *downloaded* copy
|
||||
// still needs the quarantine flag cleared), but it is no longer flagged as
|
||||
// malware and runs fine locally.
|
||||
//
|
||||
// Replace this with a Developer ID identity + notarization when the app ships.
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const path = require('node:path')
|
||||
|
||||
exports.default = async function adhocSign(context) {
|
||||
if (context.electronPlatformName !== 'darwin') return
|
||||
|
||||
const appName = context.packager.appInfo.productFilename
|
||||
const appPath = path.join(context.appOutDir, `${appName}.app`)
|
||||
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
|
||||
|
||||
console.log(` • ad-hoc signing ${appPath}`)
|
||||
execFileSync(
|
||||
'codesign',
|
||||
[
|
||||
'--force',
|
||||
'--deep',
|
||||
'--sign',
|
||||
'-',
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
entitlements,
|
||||
appPath
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
|
||||
// Fail the build rather than ship a bundle macOS will quarantine again.
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' })
|
||||
}
|
||||
17
build/entitlements.mac.plist
Normal file
17
build/entitlements.mac.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- V8 compiles JavaScript at runtime. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Electron sets dyld vars when it spawns its own helpers. -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- node-pty is a native addon signed with a different (ad-hoc) identity. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
44
electron-builder.yml
Normal file
44
electron-builder.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
appId: com.blijnder.helder
|
||||
productName: Helder
|
||||
directories:
|
||||
output: dist
|
||||
buildResources: build
|
||||
# The renderer/main/preload are already bundled into out/ by electron-vite.
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
# Native / spawned binaries must live outside the asar to load at runtime.
|
||||
# The rg binary ships in a platform package (@vscode/ripgrep-<platform>-<arch>),
|
||||
# so that must be unpacked too — not just the @vscode/ripgrep shim.
|
||||
asarUnpack:
|
||||
- '**/node_modules/node-pty/**'
|
||||
- '**/node_modules/@vscode/ripgrep/**'
|
||||
- '**/node_modules/@vscode/ripgrep-*/**'
|
||||
# Ad-hoc signs the .app on macOS. Without it the bundle keeps only Electron's
|
||||
# linker signature, seals no resources, and macOS blocks it as malware.
|
||||
afterPack: build/adhoc-sign.cjs
|
||||
mac:
|
||||
category: public.app-category.developer-tools
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
# Local/unsigned build: ad-hoc signed by electron-builder, no notarization.
|
||||
identity: null
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
files:
|
||||
# Keep the cross-build leftovers (see win:) out of the mac package.
|
||||
- '!**/node_modules/@vscode/ripgrep-win32-*/**'
|
||||
win:
|
||||
target: nsis
|
||||
# Cross-built from macOS with `-c.npmRebuild=false`: node-pty can't be
|
||||
# cross-compiled, but it ships prebuilds/win32-* and its loader falls back
|
||||
# to those when build/Release is absent. The win32 rg binary is provided by
|
||||
# manually extracting @vscode/ripgrep-win32-x64 into node_modules (npm
|
||||
# refuses to install it on darwin).
|
||||
files:
|
||||
- '!**/node_modules/node-pty/build/**'
|
||||
- '!**/node_modules/@vscode/ripgrep-darwin-*/**'
|
||||
- '!**/node_modules/@vscode/ripgrep-linux-*/**'
|
||||
linux:
|
||||
target: AppImage
|
||||
category: Development
|
||||
@@ -9,7 +9,13 @@ export default defineConfig({
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: { outDir: 'out/preload' },
|
||||
build: {
|
||||
outDir: 'out/preload',
|
||||
// CommonJS preload (.cjs): loads synchronously before the page, so the
|
||||
// contextBridge is exposed by the time the renderer mounts. ESM preload
|
||||
// (.mjs) can expose late and leave window.helder briefly undefined.
|
||||
rollupOptions: { output: { format: 'cjs', entryFileNames: 'index.cjs' } },
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: 'src/renderer',
|
||||
|
||||
46
eslint.config.js
Normal file
46
eslint.config.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['out/**', 'dist/**', 'node_modules/**', 'coverage/**', 'design_handoff_helder_workbench/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: { ...globals.browser } },
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// These newer rules fire on the canonical "load async then setState in a
|
||||
// mount effect" / "read a stable bridge in an effect" patterns we use
|
||||
// deliberately. Keep the useful exhaustive-deps warnings; drop these.
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/main/**/*.ts', 'src/preload/**/*.ts', 'electron.vite.config.ts', 'vitest.config.ts', 'eslint.config.js'],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
{
|
||||
files: ['test/**/*.{ts,tsx}'],
|
||||
languageOptions: { globals: { ...globals.node, ...globals.browser } },
|
||||
},
|
||||
{
|
||||
// electron-builder hooks: plain CommonJS, run by node outside the app.
|
||||
files: ['build/**/*.cjs'],
|
||||
languageOptions: { globals: { ...globals.node }, sourceType: 'commonjs' },
|
||||
rules: { '@typescript-eslint/no-require-imports': 'off' },
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
)
|
||||
BIN
jetbrains-mono-latin-400-normal-V6pRDFza.woff2
Normal file
BIN
jetbrains-mono-latin-400-normal-V6pRDFza.woff2
Normal file
Binary file not shown.
5601
package-lock.json
generated
5601
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -12,7 +12,13 @@
|
||||
"preview": "electron-vite preview",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.web.json && tsc --noEmit -p tsconfig.node.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint .",
|
||||
"rebuild": "electron-rebuild -f -w node-pty",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"postinstall": "electron-rebuild -f -w node-pty"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,22 +26,34 @@
|
||||
"@vscode/ripgrep": "^1.18.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"simple-git": "^3.36.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/prismjs": "^1.26.4",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"electron": "^31.3.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5"
|
||||
"typescript-eslint": "^8.61.1",
|
||||
"vite": "^5.3.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,18 +10,24 @@ import { join } from 'node:path'
|
||||
* and FONT SIZE live here (as CSS vars), not in the JSON
|
||||
* Effective value = config.json over config.default.json, merged key by key.
|
||||
*/
|
||||
export type DiffMode = 'original' | 'updated' | 'diff'
|
||||
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number }
|
||||
git: { confirmDiscard: boolean }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: { shell: string | null }
|
||||
session: { restoreOnLaunch: boolean }
|
||||
}
|
||||
|
||||
export const DEFAULTS: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4 },
|
||||
git: { confirmDiscard: true },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
||||
files: { exclude: [], followGitignore: false },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
@@ -40,9 +46,47 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
|
||||
}
|
||||
`
|
||||
|
||||
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
||||
const RECENT_FILE = 'recent.json'
|
||||
const MAX_RECENT = 100
|
||||
// The whole .helder folder is local, machine-specific state — ignore all of it.
|
||||
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit).\n*\n`
|
||||
|
||||
let current: HelderConfig = DEFAULTS
|
||||
let themeCss = ''
|
||||
|
||||
/** Ensure `.helder/.gitignore` ignores the entire folder; (re)write it when the
|
||||
* file is missing or out of date (e.g. upgrading from the old recent-only one). */
|
||||
async function ensureGitignore(dir: string): Promise<void> {
|
||||
const path = join(dir, '.gitignore')
|
||||
try {
|
||||
if ((await readFile(path, 'utf8')) === GITIGNORE_BODY) return
|
||||
} catch {
|
||||
/* missing — fall through to write */
|
||||
}
|
||||
await writeFile(path, GITIGNORE_BODY)
|
||||
}
|
||||
|
||||
export async function getRecent(root: string): Promise<string[]> {
|
||||
try {
|
||||
const arr = JSON.parse(await readFile(join(root, '.helder', RECENT_FILE), 'utf8'))
|
||||
return Array.isArray(arr) ? arr.filter((p): p is string => typeof p === 'string').slice(0, MAX_RECENT) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRecent(root: string, list: string[]): Promise<void> {
|
||||
try {
|
||||
const dir = join(root, '.helder')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await ensureGitignore(dir)
|
||||
await writeFile(join(dir, RECENT_FILE), JSON.stringify(list.slice(0, MAX_RECENT), null, 2) + '\n')
|
||||
} catch {
|
||||
/* read-only / inaccessible root — recents just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return !!v && typeof v === 'object' && !Array.isArray(v)
|
||||
}
|
||||
@@ -77,6 +121,8 @@ export async function resolveConfig(root: string): Promise<void> {
|
||||
themeCss = THEME_TEMPLATE
|
||||
await writeFile(join(dir, 'theme.css'), THEME_TEMPLATE)
|
||||
}
|
||||
|
||||
await ensureGitignore(dir)
|
||||
} catch {
|
||||
// Read-only / inaccessible root: fall back to built-in defaults.
|
||||
current = DEFAULTS
|
||||
|
||||
188
src/main/diagnostics.ts
Normal file
188
src/main/diagnostics.ts
Normal 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 }
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, sep } from 'node:path'
|
||||
import { listFiles, rgAvailable } from './search-service'
|
||||
|
||||
export interface FileNode {
|
||||
name: string
|
||||
@@ -9,7 +10,7 @@ export interface FileNode {
|
||||
children?: FileNode[]
|
||||
}
|
||||
|
||||
/** Directories never walked — noise or huge, and not part of "the project". */
|
||||
/** Directories never walked by the fallback (rg already honors these as globs). */
|
||||
const IGNORE_DIRS = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
@@ -22,10 +23,149 @@ function ignored(name: string): boolean {
|
||||
return IGNORE_DIRS.has(name) || name === '.DS_Store'
|
||||
}
|
||||
|
||||
/** Recursive project tree, dirs first then files, alphabetical. */
|
||||
function looksBinary(buf: Buffer): boolean {
|
||||
const n = Math.min(buf.length, 8000)
|
||||
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- tree from a flat path list (the rg-backed primary path) ----------------
|
||||
|
||||
function sortTree(node: FileNode): void {
|
||||
if (!node.children) return
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
for (const c of node.children) sortTree(c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested tree from relative file paths (dirs first, alphabetical).
|
||||
* `dirPaths` are directories to force into the tree even when they hold no
|
||||
* files — empty folders that `rg --files` can never emit (see `listEmptyDirs`).
|
||||
*/
|
||||
export function buildTreeFromPaths(rootName: string, paths: string[], dirPaths: string[] = []): FileNode {
|
||||
const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
|
||||
const dirs = new Map<string, FileNode>([['', root]])
|
||||
|
||||
/** Ensure a directory node (and all its ancestors) exist; return the node. */
|
||||
function ensureDir(rel: string): FileNode {
|
||||
const existing = dirs.get(rel)
|
||||
if (existing) return existing
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
let parent = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||
let dir = dirs.get(curPath)
|
||||
if (!dir) {
|
||||
dir = { name: parts[i], type: 'dir', path: curPath, open: i === 0, children: [] }
|
||||
dirs.set(curPath, dir)
|
||||
parent.children!.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
parentPath = curPath
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
for (const rel of paths) {
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
if (!parts.length) continue
|
||||
const parent = ensureDir(parts.slice(0, -1).join('/'))
|
||||
parent.children!.push({ name: parts[parts.length - 1], type: 'file', path: rel })
|
||||
}
|
||||
for (const rel of dirPaths) if (rel) ensureDir(rel)
|
||||
sortTree(root)
|
||||
return root
|
||||
}
|
||||
|
||||
function rootName(root: string): string {
|
||||
return root.split(sep).filter(Boolean).pop() || root
|
||||
}
|
||||
|
||||
/** Project tree. Primary: rg file list (honors gitignore + excludes) plus the
|
||||
* empty folders rg can't emit. Fallback: a plain recursive walk (when ripgrep
|
||||
* is unavailable) — that already lists empty dirs. */
|
||||
export async function readTree(root: string): Promise<FileNode> {
|
||||
const name = root.split(sep).filter(Boolean).pop() || root
|
||||
return { name, type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
const [paths, emptyDirs] = await Promise.all([
|
||||
listFiles(root).catch(() => [] as string[]),
|
||||
listEmptyDirs(root).catch(() => [] as string[]),
|
||||
])
|
||||
if (paths.length || emptyDirs.length) return buildTreeFromPaths(rootName(root), paths, emptyDirs)
|
||||
return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative paths of directories whose entire subtree holds no files
|
||||
* ("file-empty" folders). `rg --files` lists files only, so an empty folder has
|
||||
* nothing for it to emit and the Explorer would never show it until its first
|
||||
* file lands (and not even after a restart). We inject these alongside the rg
|
||||
* list so a freshly created folder shows up immediately.
|
||||
*
|
||||
* Only *file-empty* dirs are injected, never a dir that contains files: a dir
|
||||
* with files is already represented by those files (gitignore-filtered by rg),
|
||||
* so this can never resurrect a gitignored content directory. Traversal honors
|
||||
* the same IGNORE_DIRS as the fallback walk. `scope` (an absolute dir inside
|
||||
* root) restricts the walk; returned paths stay relative to root.
|
||||
*/
|
||||
export async function listEmptyDirs(root: string, scope?: string): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
/** Walk `abs`; return whether its subtree contains at least one file. */
|
||||
async function walk(abs: string, depth: number): Promise<boolean> {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = await readdir(abs, { withFileTypes: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
let hasFile = false
|
||||
const subdirs: string[] = []
|
||||
for (const e of entries) {
|
||||
if (ignored(e.name)) continue
|
||||
if (e.isFile()) hasFile = true
|
||||
else if (e.isDirectory()) subdirs.push(join(abs, e.name))
|
||||
}
|
||||
for (const childAbs of subdirs) {
|
||||
const childHasFile = depth < 12 ? await walk(childAbs, depth + 1) : false
|
||||
if (childHasFile) hasFile = true
|
||||
else out.push(relative(root, childAbs).split(sep).join('/'))
|
||||
}
|
||||
return hasFile
|
||||
}
|
||||
await walk(scope || root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Find the node at a root-relative path inside a tree ('' is the root). */
|
||||
function findNode(tree: FileNode, rel: string): FileNode | null {
|
||||
if (rel === '') return tree
|
||||
let node: FileNode | null = tree
|
||||
for (const part of rel.split('/').filter(Boolean)) {
|
||||
node = node?.children?.find((c) => c.name === part) ?? null
|
||||
if (!node) return null
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh children for a single directory (root-relative path; '' = root). Used to
|
||||
* re-read a folder on expand/collapse so newly added/removed files show up
|
||||
* without a full tree walk. Stays consistent with the initial tree: rg-backed
|
||||
* (honors gitignore + excludes), with the recursive-walk fallback only when
|
||||
* ripgrep is unavailable.
|
||||
*/
|
||||
export async function readDirChildren(root: string, rel: string): Promise<FileNode[]> {
|
||||
const abs = rel ? join(root, rel) : root
|
||||
if (await rgAvailable()) {
|
||||
const [paths, emptyDirs] = await Promise.all([
|
||||
listFiles(root, abs).catch(() => [] as string[]),
|
||||
listEmptyDirs(root, abs).catch(() => [] as string[]),
|
||||
])
|
||||
return findNode(buildTreeFromPaths(rootName(root), paths, emptyDirs), rel)?.children ?? []
|
||||
}
|
||||
return readDir(abs, root, 0)
|
||||
}
|
||||
|
||||
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||
@@ -55,12 +195,6 @@ async function readDir(abs: string, root: string, depth: number): Promise<FileNo
|
||||
return [...dirs, ...files]
|
||||
}
|
||||
|
||||
function looksBinary(buf: Buffer): boolean {
|
||||
const n = Math.min(buf.length, 8000)
|
||||
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Read a single text file (relative path) → string. */
|
||||
export async function readProjectFile(root: string, rel: string): Promise<string> {
|
||||
const buf = await readFile(join(root, rel))
|
||||
@@ -68,20 +202,105 @@ export async function readProjectFile(root: string, rel: string): Promise<string
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
|
||||
ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng',
|
||||
}
|
||||
const MAX_IMAGE_BYTES = 25_000_000
|
||||
|
||||
/** Read an image file as a `data:` URL for the viewer's <img> — the renderer
|
||||
* can't touch the filesystem, and a data URL sidesteps file:// path/escaping
|
||||
* concerns entirely. Returns '' for a non-image extension, a path escaping the
|
||||
* root, or an oversized/unreadable file. */
|
||||
export async function readImageDataUrl(root: string, rel: string): Promise<string> {
|
||||
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
|
||||
const mime = IMAGE_MIME[ext]
|
||||
if (!mime) return ''
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) return ''
|
||||
try {
|
||||
const buf = await readFile(target)
|
||||
if (buf.length > MAX_IMAGE_BYTES) return ''
|
||||
return `data:${mime};base64,${buf.toString('base64')}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a text file (relative path). Used by the editable buffer's save. */
|
||||
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
|
||||
await writeFile(join(root, rel), content, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory content index of all (small, text) files — powers content
|
||||
* search and plain-file viewing without touching disk per keystroke. Capped to
|
||||
* keep large repos sane. PHASE: swap content search to ripgrep when scaling up.
|
||||
* Create a new, empty text file (relative path). Creates parent folders as
|
||||
* needed, refuses to escape the project root, and throws if the file already
|
||||
* exists so an accidental name collision never clobbers existing content.
|
||||
*/
|
||||
export async function createProjectFile(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
|
||||
const existing = await stat(target).catch(() => null)
|
||||
if (existing) throw new Error('file already exists')
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, '', { encoding: 'utf8', flag: 'wx' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new, empty folder (relative path). Creates parent folders as needed,
|
||||
* refuses to escape the project root, and throws if the folder already exists so
|
||||
* a name collision is surfaced rather than silently swallowed. The empty folder
|
||||
* shows up in the tree immediately (see `listEmptyDirs`).
|
||||
*/
|
||||
export async function createProjectDir(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) throw new Error('outside project root')
|
||||
const existing = await stat(target).catch(() => null)
|
||||
if (existing) throw new Error('folder already exists')
|
||||
await mkdir(target, { recursive: true })
|
||||
}
|
||||
|
||||
/** Delete a project file or folder (relative path). Stays inside the project root. */
|
||||
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
||||
const target = join(root, rel)
|
||||
if (relative(root, target).startsWith('..')) return // guard against escaping the root
|
||||
await rm(target, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory content index of all (small, text) files — powers content viewing.
|
||||
* Primary: read the rg file list; fallback: walk. Capped for large repos.
|
||||
*/
|
||||
export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
const paths = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return readListed(root, paths)
|
||||
return readAllWalk(root)
|
||||
}
|
||||
|
||||
async function readListed(root: string, paths: string[]): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
for (const rel of paths) {
|
||||
if (count >= MAX_INDEXED_FILES) break
|
||||
try {
|
||||
const abs = join(root, rel)
|
||||
const s = await stat(abs)
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(abs)
|
||||
if (looksBinary(buf)) continue
|
||||
out[rel] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function readAllWalk(root: string): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {}
|
||||
let count = 0
|
||||
async function walk(abs: string): Promise<void> {
|
||||
if (count >= MAX_INDEXED_FILES) return
|
||||
let entries: import('node:fs').Dirent[]
|
||||
@@ -102,8 +321,7 @@ export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
if (s.size > MAX_FILE_BYTES) continue
|
||||
const buf = await readFile(childAbs)
|
||||
if (looksBinary(buf)) continue
|
||||
const rel = relative(root, childAbs).split(sep).join('/')
|
||||
out[rel] = buf.toString('utf8')
|
||||
out[relative(root, childAbs).split(sep).join('/')] = buf.toString('utf8')
|
||||
count++
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
@@ -111,7 +329,6 @@ export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { 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'
|
||||
|
||||
@@ -17,28 +36,131 @@ export interface GitLoad {
|
||||
changes: GitChange[]
|
||||
}
|
||||
|
||||
function git(root: string): SimpleGit {
|
||||
return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 })
|
||||
interface GitResult {
|
||||
code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
/** Map a porcelain code pair to our display letter + staged flag. */
|
||||
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
const staged = index !== ' ' && index !== '?'
|
||||
const code = staged ? index : working
|
||||
let letter: GitStatusLetter
|
||||
/**
|
||||
* Run a git subcommand directly via child_process.
|
||||
*
|
||||
* We deliberately spawn `git` ourselves instead of going through simple-git:
|
||||
* inside Electron's main process simple-git's spawn (which wires up a stdin
|
||||
* pipe) trips `spawn EBADF` on macOS. Forcing stdin to 'ignore' — the child
|
||||
* never reads input — sidesteps the bad-descriptor crash entirely.
|
||||
*/
|
||||
function runGit(root: string, args: string[]): Promise<GitResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('git', args, {
|
||||
cwd: root,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (d: string) => { stdout += d })
|
||||
child.stderr.on('data', (d: string) => { stderr += d })
|
||||
child.on('error', reject)
|
||||
child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
/** Run git, rejecting on a non-zero exit. */
|
||||
async function git(root: string, args: string[]): Promise<string> {
|
||||
const { code, stdout, stderr } = await runGit(root, args)
|
||||
if (code !== 0) throw new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** Map one porcelain status code to our display letter. */
|
||||
function letterFor(code: string): GitStatusLetter {
|
||||
switch (code) {
|
||||
case 'A': case 'C': case '?': letter = 'A'; break
|
||||
case 'D': letter = 'D'; break
|
||||
case 'R': letter = 'R'; break
|
||||
case 'U': letter = 'M'; break
|
||||
case 'M': default: letter = 'M'; break
|
||||
case 'A': case 'C': case '?': return 'A'
|
||||
case 'D': return 'D'
|
||||
case 'R': return 'R'
|
||||
case 'U': return 'M'
|
||||
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 {
|
||||
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 {
|
||||
return ''
|
||||
}
|
||||
@@ -57,52 +179,149 @@ async function diskText(root: string, path: string): Promise<string> {
|
||||
|
||||
export async function isRepo(root: string): Promise<boolean> {
|
||||
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 {
|
||||
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> {
|
||||
const g = git(root)
|
||||
// Coalesce overlapping loads per root: a branch switch or rapid edits fire
|
||||
// several watcher pings, each triggering a reload. We keep at most one read in
|
||||
// flight plus one trailing read (which captures whatever changed during the
|
||||
// first), so the panel always settles on fresh state without a spawn pile-up.
|
||||
const loadInFlight = new Map<string, Promise<GitLoad | null>>()
|
||||
const loadPending = new Map<string, Promise<GitLoad | null>>()
|
||||
|
||||
export function load(root: string): Promise<GitLoad | null> {
|
||||
const running = loadInFlight.get(root)
|
||||
if (running) {
|
||||
let pending = loadPending.get(root)
|
||||
if (!pending) {
|
||||
pending = running.catch(() => {}).then(() => {
|
||||
loadPending.delete(root)
|
||||
return startLoad(root)
|
||||
})
|
||||
loadPending.set(root, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
return startLoad(root)
|
||||
}
|
||||
|
||||
function startLoad(root: string): Promise<GitLoad | null> {
|
||||
const p = doLoad(root).finally(() => {
|
||||
if (loadInFlight.get(root) === p) loadInFlight.delete(root)
|
||||
})
|
||||
loadInFlight.set(root, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function doLoad(root: string): Promise<GitLoad | null> {
|
||||
if (!(await isRepo(root))) return null
|
||||
|
||||
const status = await g.status()
|
||||
const branch = status.current || 'HEAD'
|
||||
|
||||
const changes: GitChange[] = []
|
||||
for (const f of status.files) {
|
||||
// simple-git uses path "from -> to" for renames; take the destination.
|
||||
const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path
|
||||
const { letter, staged } = classify(f.index, f.working_dir)
|
||||
const isNew = f.index === '?' || f.index === 'A'
|
||||
const isDeleted = letter === 'D'
|
||||
const original = isNew ? '' : await headText(g, path)
|
||||
const updated = isDeleted ? '' : await diskText(root, path)
|
||||
changes.push({ path, status: letter, staged, original, updated })
|
||||
// A branch switch / checkout from an external tool (Sublime Merge, the CLI)
|
||||
// rewrites .git and briefly holds .git/index.lock; a status that lands in
|
||||
// that window fails. Retry so we settle on the real new state instead of
|
||||
// rejecting (which would leave the git column stale or blank).
|
||||
let raw: string | null = null
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
try {
|
||||
raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z'])
|
||||
break
|
||||
} catch (err) {
|
||||
if (attempt === 5) throw err
|
||||
await delay(120)
|
||||
}
|
||||
}
|
||||
if (raw == null) return null
|
||||
|
||||
const { branch, files } = parseStatus(raw)
|
||||
// Each changed file needs its HEAD blob (a `git show` spawn) + its disk text.
|
||||
// Done serially this is O(files) subprocess spawns in a row — staging one file
|
||||
// re-reads ALL of them, which is the dominant cost of a reload. Run them with
|
||||
// bounded concurrency instead so the spawns overlap (cap keeps us well under
|
||||
// macOS's low default FD limit). Order is preserved by index.
|
||||
const changes = (await mapLimit(files, 12, async (f) => {
|
||||
const rows = classify(f.index, f.working)
|
||||
const stagedRow = rows.find((r) => r.staged)
|
||||
const workRow = rows.find((r) => !r.staged)
|
||||
// The index blob is only needed when a file sits in BOTH groups. With one
|
||||
// row the index copy equals HEAD (unstaged only) or the disk copy (staged
|
||||
// only), so the common case still costs no extra `git show`.
|
||||
const both = !!stagedRow && !!workRow
|
||||
const idx = both ? await indexText(root, f.path) : ''
|
||||
const out: GitChange[] = []
|
||||
if (stagedRow) {
|
||||
// Staged row: HEAD -> index.
|
||||
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
|
||||
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
|
||||
}
|
||||
if (workRow) {
|
||||
// Unstaged row: index -> disk. An untracked file has no index copy.
|
||||
const untracked = f.index === '?'
|
||||
const original = untracked ? '' : both ? idx : await headText(root, f.path)
|
||||
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
|
||||
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
|
||||
}
|
||||
return out
|
||||
})).flat()
|
||||
|
||||
return { branch, changes }
|
||||
}
|
||||
|
||||
export async function stage(root: string, paths: string[]): Promise<void> {
|
||||
// `git add` stages modifications, additions AND deletions of the given paths.
|
||||
await git(root).add(paths)
|
||||
await git(root, ['add', '--', ...paths])
|
||||
}
|
||||
|
||||
export async function unstage(root: string, paths: string[]): Promise<void> {
|
||||
try {
|
||||
await git(root).reset(['--', ...paths])
|
||||
} catch {
|
||||
const { code } = await runGit(root, ['reset', '--', ...paths])
|
||||
if (code !== 0) {
|
||||
// empty repo (no HEAD yet): fall back to removing from the index.
|
||||
await git(root).raw(['rm', '--cached', '-r', '--', ...paths])
|
||||
await git(root, ['rm', '--cached', '-r', '--', ...paths])
|
||||
}
|
||||
}
|
||||
|
||||
export async function commit(root: string, message: string): Promise<void> {
|
||||
await git(root).commit(message)
|
||||
await git(root, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
await git(root).checkout(['--', ...paths])
|
||||
/**
|
||||
* Push the current branch to its remote. If the branch has no upstream yet,
|
||||
* retry with `-u origin <branch>` so the first push also sets tracking.
|
||||
* Returns a concise one-line summary for the toast (git writes progress to
|
||||
* stderr, so we pull the summary from there).
|
||||
*/
|
||||
export async function push(root: string): Promise<{ ok: boolean; message: string }> {
|
||||
let res = await runGit(root, ['push'])
|
||||
if (res.code !== 0 && /no upstream branch|set-upstream/i.test(res.stderr)) {
|
||||
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
|
||||
if (branch && branch !== 'HEAD') res = await runGit(root, ['push', '-u', 'origin', branch])
|
||||
}
|
||||
const lines = (res.stderr || res.stdout).trim().split('\n').map((l) => l.trim()).filter(Boolean)
|
||||
if (res.code !== 0) return { ok: false, message: lines.pop() || 'push failed' }
|
||||
const summary = lines.find((l) => /->|up-to-date|new branch/i.test(l)) || lines.pop() || 'Pushed'
|
||||
return { ok: true, message: summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard working-tree changes for each path:
|
||||
* - exists in HEAD → restore index + worktree to the last commit
|
||||
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk
|
||||
*/
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
for (const p of paths) {
|
||||
const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`])
|
||||
if (code === 0) {
|
||||
await git(root, ['checkout', 'HEAD', '--', p])
|
||||
} else {
|
||||
await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result
|
||||
await rm(join(root, p), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
||||
import { watch, type FSWatcher } from 'chokidar'
|
||||
import { getName, getRoot, openDialog } from './project'
|
||||
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
||||
import { commit, discard, load, stage, unstage } from './git-service'
|
||||
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
||||
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
||||
import { commit, discard, load, push, stage, unstage } from './git-service'
|
||||
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||
import { getConfig, getThemeCss, resolveConfig } from './config'
|
||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||
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 isMac = process.platform === 'darwin'
|
||||
|
||||
// Before anything else can fail: start the crash reporter, open the log file and
|
||||
// hook uncaughtException / render-process-gone / preload-error. Everything below
|
||||
// (including a throw at module load) is logged from here on.
|
||||
initDiagnostics(isDev)
|
||||
|
||||
const WATCH_IGNORE = new Set([
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
|
||||
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
|
||||
@@ -18,12 +28,40 @@ const WATCH_IGNORE = new Set([
|
||||
|
||||
let watcher: FSWatcher | null = null
|
||||
let configWatcher: FSWatcher | null = null
|
||||
let gitWatcher: FSWatcher | null = null
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let gitTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function broadcast(channel: string): void {
|
||||
for (const w of BrowserWindow.getAllWindows()) w.webContents.send(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Each Helder window is one project, and one project owns global main-process
|
||||
* state (root + FS/git/config watchers + PTYs). To run several projects beside
|
||||
* each other we therefore spawn a *separate, detached* Helder process per window
|
||||
* rather than opening a second BrowserWindow in this process.
|
||||
*
|
||||
* Spawning `process.execPath` directly also sidesteps macOS LaunchServices, which
|
||||
* otherwise just re-activates the running instance when the bundle is launched
|
||||
* again from Finder/Dock. `HELDER_PROJECT` points the child at a project (the
|
||||
* child's `resolveInitialRoot` reads it); with no path it lands on the launcher.
|
||||
*/
|
||||
function spawnInstance(projectPath?: string): void {
|
||||
const env = { ...process.env }
|
||||
if (projectPath && safeIsDir(projectPath)) env.HELDER_PROJECT = resolve(projectPath)
|
||||
else delete env.HELDER_PROJECT
|
||||
// Packaged: execPath IS the app, no app path needed. Dev: replay our own argv
|
||||
// (the electron-vite main entry) so the child loads the same app.
|
||||
const args = app.isPackaged ? [] : process.argv.slice(1)
|
||||
const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', env })
|
||||
child.unref()
|
||||
}
|
||||
|
||||
function safeIsDir(p: string): boolean {
|
||||
try { return statSync(p).isDirectory() } catch { return false }
|
||||
}
|
||||
|
||||
function startConfigWatcher(): void {
|
||||
if (configWatcher) { configWatcher.close(); configWatcher = null }
|
||||
const root = getRoot()
|
||||
@@ -34,6 +72,136 @@ function startConfigWatcher(): void {
|
||||
configWatcher.on('add', reload).on('change', reload).on('unlink', reload)
|
||||
}
|
||||
|
||||
/** Resolve the real git directory for a project root. Normally this is
|
||||
* `<root>/.git`, but for worktrees/submodules `.git` is a file containing
|
||||
* `gitdir: <path>` pointing at the actual directory. */
|
||||
function gitDir(root: string): string {
|
||||
const dot = join(root, '.git')
|
||||
try {
|
||||
if (statSync(dot).isFile()) {
|
||||
const m = readFileSync(dot, 'utf8').trim().match(/^gitdir:\s*(.+)$/)
|
||||
if (m) return resolve(root, m[1].trim())
|
||||
}
|
||||
} catch { /* not a worktree, or .git missing */ }
|
||||
return dot
|
||||
}
|
||||
|
||||
/** The main watcher ignores `.git`, so branch switches / commits / staging
|
||||
* done by external tools (Sublime Merge, the CLI, …) would never refresh the
|
||||
* git column. Watch the few git files that mark those events so the renderer
|
||||
* re-reads status: HEAD (branch switch), index (staging), refs/heads + logs
|
||||
* (commits), MERGE_HEAD (in-progress merge). */
|
||||
function startGitWatcher(): void {
|
||||
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
const dir = gitDir(root)
|
||||
gitWatcher = watch(
|
||||
[
|
||||
join(dir, 'HEAD'),
|
||||
join(dir, 'index'),
|
||||
join(dir, 'refs', 'heads'),
|
||||
join(dir, 'logs', 'HEAD'),
|
||||
join(dir, 'MERGE_HEAD'),
|
||||
],
|
||||
{ ignoreInitial: true },
|
||||
)
|
||||
const ping = (): void => {
|
||||
if (gitTimer) clearTimeout(gitTimer)
|
||||
gitTimer = setTimeout(() => broadcast('project:changed'), 200)
|
||||
}
|
||||
gitWatcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
|
||||
}
|
||||
|
||||
/** Show the folder picker; if a new folder is chosen, switch the project
|
||||
* (config + watchers). Returns whether the project changed. */
|
||||
async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
|
||||
const next = await openDialog(win)
|
||||
if (!next) return false
|
||||
await resolveConfig(next)
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
startGitWatcher()
|
||||
syncWindowTitle()
|
||||
return true
|
||||
}
|
||||
|
||||
function buildAppMenu(): Menu {
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac ? [{ role: 'appMenu' as const }] : []),
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Window',
|
||||
accelerator: 'CmdOrCtrl+Shift+N',
|
||||
click: () => spawnInstance(),
|
||||
},
|
||||
{
|
||||
label: 'Open Folder…',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: (_m, win) => {
|
||||
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
|
||||
openFolderFlow(bw).then((changed) => { if (changed) broadcast('project:changed') })
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
isMac ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
// ⌘R refreshes the three left columns instead of reloading the window:
|
||||
// git status (Col A), the file explorer (Col B), and the open file in the
|
||||
// viewer re-read from disk (Col C). We send a dedicated `view:refresh`
|
||||
// ping — distinct from the disk watchers' `project:changed` — so only an
|
||||
// explicit ⌘R force-reloads the viewer from disk; background watcher pings
|
||||
// keep refreshing git + tree without blowing away the editor buffer.
|
||||
// Reload / Force Reload are intentionally omitted so ⌘R never blows away
|
||||
// app state.
|
||||
{
|
||||
label: 'Refresh',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click: (_m, win) => {
|
||||
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
|
||||
bw?.webContents.send('view:refresh')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{ role: 'windowMenu' },
|
||||
{
|
||||
role: 'help',
|
||||
submenu: [
|
||||
// A crash you can't read is a crash you can't fix — keep the log one
|
||||
// click away rather than buried in ~/Library/Logs.
|
||||
{ label: 'Open Log', click: () => openLog() },
|
||||
{ label: 'Reveal Log in Finder', click: () => revealLog() },
|
||||
],
|
||||
},
|
||||
]
|
||||
return Menu.buildFromTemplate(template)
|
||||
}
|
||||
|
||||
/** macOS Dock right-click menu. Sits above the system items (Show All Windows,
|
||||
* Hide, Quit) that macOS appends itself. It mirrors File → New Window so a new
|
||||
* project window is one right-click away, even with no window focused. */
|
||||
function buildDockMenu(): Menu {
|
||||
return Menu.buildFromTemplate([
|
||||
{ label: 'New Window', click: () => spawnInstance() },
|
||||
])
|
||||
}
|
||||
|
||||
function startWatcher(): void {
|
||||
if (watcher) { watcher.close(); watcher = null }
|
||||
const root = getRoot()
|
||||
@@ -49,41 +217,131 @@ function startWatcher(): void {
|
||||
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||||
ipcMain.handle('project:open', async (e) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
const next = await openDialog(win)
|
||||
if (next) {
|
||||
await resolveConfig(getRoot())
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
/**
|
||||
* ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
|
||||
* `catch {}` that showed a generic toast ("Create failed") and dropped the
|
||||
* actual cause, so the log records the channel, its args and the error, then
|
||||
* RETHROWS so the renderer keeps behaving exactly as before.
|
||||
*
|
||||
* Slow calls get a line too: an FS/git handler blocking for seconds is the
|
||||
* symptom that precedes a beachball, and it's invisible otherwise.
|
||||
*/
|
||||
const SLOW_MS = 1000
|
||||
|
||||
function handle(channel: string, fn: (e: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown): void {
|
||||
ipcMain.handle(channel, async (e, ...args) => {
|
||||
const started = Date.now()
|
||||
try {
|
||||
const out = await fn(e, ...(args as never[]))
|
||||
const ms = Date.now() - started
|
||||
if (ms >= SLOW_MS) logger.warn('ipc', `${channel} slow`, { ms, args: previewArgs(args) })
|
||||
return out
|
||||
} catch (err) {
|
||||
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args), ms: Date.now() - started })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Log-safe args: file CONTENT (fs:write) would swamp the log, so cap length. */
|
||||
function previewArgs(args: unknown[]): unknown[] {
|
||||
return args.map((a) => (typeof a === 'string' && a.length > 120 ? `${a.slice(0, 120)}… (${a.length} chars)` : a))
|
||||
}
|
||||
|
||||
/** Same, for the fire-and-forget `ipcMain.on` channels. */
|
||||
function on(channel: string, fn: (e: Electron.IpcMainEvent, ...args: never[]) => void): void {
|
||||
ipcMain.on(channel, (e, ...args) => {
|
||||
try {
|
||||
fn(e, ...(args as never[]))
|
||||
} catch (err) {
|
||||
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
handle('project:current', () => ({ root: getRoot(), name: getName() }))
|
||||
handle('project:open', async (e) => {
|
||||
await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
|
||||
return { root: getRoot(), name: getName() }
|
||||
})
|
||||
handle('projects:recent', () => getRecentProjects())
|
||||
handle('project:openPath', async (_e, path: string) => {
|
||||
setRoot(path)
|
||||
const r = getRoot()
|
||||
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
|
||||
syncWindowTitle()
|
||||
broadcast('project:changed')
|
||||
return { root: getRoot(), name: getName() }
|
||||
})
|
||||
|
||||
ipcMain.handle('fs:tree', () => readTree(getRoot()))
|
||||
ipcMain.handle('fs:files', () => readAll(getRoot()))
|
||||
ipcMain.handle('fs:read', (_e, rel: string) => readProjectFile(getRoot(), rel))
|
||||
ipcMain.handle('fs:write', (_e, rel: string, content: string) => writeProjectFile(getRoot(), rel, content))
|
||||
handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
|
||||
handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
||||
handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
|
||||
handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
|
||||
handle('fs:imageDataUrl', (_e, rel: string) => { const r = getRoot(); return r ? readImageDataUrl(r, rel) : '' })
|
||||
handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
|
||||
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
|
||||
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
|
||||
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
|
||||
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
|
||||
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
|
||||
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })
|
||||
|
||||
ipcMain.handle('git:load', () => load(getRoot()))
|
||||
ipcMain.handle('git:stage', (_e, paths: string[]) => stage(getRoot(), paths))
|
||||
ipcMain.handle('git:unstage', (_e, paths: string[]) => unstage(getRoot(), paths))
|
||||
ipcMain.handle('git:commit', (_e, message: string) => commit(getRoot(), message))
|
||||
ipcMain.handle('git:discard', (_e, paths: string[]) => discard(getRoot(), paths))
|
||||
handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
||||
|
||||
ipcMain.handle('pty:available', () => ptyAvailable())
|
||||
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
|
||||
ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data))
|
||||
ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
|
||||
ipcMain.on('pty:kill', (_e, id: number) => killPty(id))
|
||||
handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||
handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
|
||||
handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
|
||||
handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
|
||||
handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } })
|
||||
handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
|
||||
|
||||
ipcMain.handle('config:get', () => getConfig())
|
||||
ipcMain.handle('config:theme', () => getThemeCss())
|
||||
handle('pty:available', () => ptyAvailable())
|
||||
handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
|
||||
on('pty:write', (_e, id: number, data: string) => writePty(id, data))
|
||||
on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
|
||||
on('pty:kill', (_e, id: number) => killPty(id))
|
||||
|
||||
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
|
||||
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
||||
handle('config:get', () => getConfig())
|
||||
handle('config:theme', () => getThemeCss())
|
||||
|
||||
handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
|
||||
handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
|
||||
|
||||
handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
|
||||
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
|
||||
|
||||
// The renderer's window into the same log file (see renderer/src/log.ts): its
|
||||
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
|
||||
// main-process and renderer failures interleave in ONE chronological file.
|
||||
on('log:write', (_e, level: LogLevel, scope: string, msg: string, ctx?: unknown) => {
|
||||
log(level, scope, msg, ctx)
|
||||
})
|
||||
handle('log:path', () => getLogPath())
|
||||
handle('log:open', () => openLog())
|
||||
handle('log:reveal', () => revealLog())
|
||||
|
||||
handle('dialog:unsavedClose', async (e, path: string) => {
|
||||
const win = BrowserWindow.fromWebContents(e.sender)
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'warning',
|
||||
buttons: ['Save', "Don't Save", 'Cancel'],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
message: `Save changes to ${path}?`,
|
||||
detail: 'Your changes will be lost if you don’t save them.',
|
||||
}
|
||||
const { response } = win ? await dialog.showMessageBox(win, opts) : await dialog.showMessageBox(opts)
|
||||
return response === 0 ? 'save' : response === 1 ? 'discard' : 'cancel'
|
||||
})
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -94,18 +352,33 @@ function createWindow(): void {
|
||||
minHeight: 680,
|
||||
show: false,
|
||||
backgroundColor: '#16171a',
|
||||
title: getName() || 'Helder',
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'default',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
preload: join(__dirname, '../preload/index.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
|
||||
win.on('page-title-updated', (e) => e.preventDefault())
|
||||
win.on('ready-to-show', () => win.show())
|
||||
|
||||
// macOS hides the traffic lights in fullscreen, so the title bar can drop the
|
||||
// 82px it reserves for them. Only the main process knows this state, hence IPC.
|
||||
function sendFullscreen(): void {
|
||||
if (win.isDestroyed()) return
|
||||
win.webContents.send('window:fullscreen', win.isFullScreen())
|
||||
}
|
||||
win.on('enter-full-screen', sendFullscreen)
|
||||
win.on('leave-full-screen', sendFullscreen)
|
||||
win.webContents.on('did-finish-load', sendFullscreen)
|
||||
|
||||
watchWindow(win)
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
@@ -119,21 +392,36 @@ function createWindow(): void {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
app.setName('Helder')
|
||||
// app.setName already ran in initDiagnostics — it has to happen before
|
||||
// app.getPath('logs') resolves, or the log lands in ~/Library/Logs/Electron.
|
||||
Menu.setApplicationMenu(buildAppMenu())
|
||||
// app.dock exists on macOS only.
|
||||
app.dock?.setMenu(buildDockMenu())
|
||||
registerIpc()
|
||||
await resolveConfig(getRoot())
|
||||
const initialRoot = getRoot()
|
||||
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
|
||||
if (initialRoot) {
|
||||
await resolveConfig(initialRoot)
|
||||
await addRecentProject(initialRoot)
|
||||
startWatcher()
|
||||
startConfigWatcher()
|
||||
startGitWatcher()
|
||||
}
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
}).catch((e) => {
|
||||
// A throw in startup (bad config, unreadable project) otherwise leaves a
|
||||
// window-less app with no message at all.
|
||||
logger.error('session', 'startup failed', e)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (watcher) { watcher.close(); watcher = null }
|
||||
if (configWatcher) { configWatcher.close(); configWatcher = null }
|
||||
if (gitWatcher) { gitWatcher.close(); gitWatcher = null }
|
||||
killAllPtys()
|
||||
if (!isMac) app.quit()
|
||||
})
|
||||
|
||||
169
src/main/logger.ts
Normal file
169
src/main/logger.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* The app's one log sink: a plain-text file under the OS log dir, written
|
||||
* SYNCHRONOUSLY so a line survives the process dying moments later.
|
||||
*
|
||||
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
|
||||
* process per project window, and every window past the first is spawned by
|
||||
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
|
||||
* from Finder there's no terminal attached either. Before this, a crash left
|
||||
* literally no trace; that's what made "it crashed sometimes" undebuggable.
|
||||
*
|
||||
* This module deliberately does NOT import electron, so it stays unit-testable.
|
||||
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
|
||||
*/
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
|
||||
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
|
||||
const MAX_BYTES = 2 * 1024 * 1024
|
||||
const KEEP = 3
|
||||
|
||||
interface LoggerState {
|
||||
file: string | null
|
||||
dir: string | null
|
||||
min: number
|
||||
mirror: boolean
|
||||
/** Byte size tracked in-process so the common path avoids a stat() per line. */
|
||||
size: number
|
||||
}
|
||||
|
||||
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
|
||||
|
||||
/** Absolute path of the active log file, or null before initLogger(). */
|
||||
export function getLogPath(): string | null {
|
||||
return state.file
|
||||
}
|
||||
|
||||
export function getLogDir(): string | null {
|
||||
return state.dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Point the logger at `dir` (created if needed). Safe to call once per process.
|
||||
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
|
||||
* terminal IS attached. `level` gates the floor (default: everything).
|
||||
*/
|
||||
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
|
||||
state.dir = opts.dir
|
||||
state.file = join(opts.dir, 'helder.log')
|
||||
state.mirror = !!opts.mirror
|
||||
state.min = LEVELS[opts.level ?? 'debug']
|
||||
try {
|
||||
mkdirSync(opts.dir, { recursive: true })
|
||||
state.size = statSync(state.file).size
|
||||
} catch {
|
||||
// Missing file is the normal first-run case (size stays 0). A genuinely
|
||||
// unwritable dir surfaces on the first write() instead, which no-ops.
|
||||
state.size = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
|
||||
* file crosses MAX_BYTES. Several project processes share one file and could in
|
||||
* principle rotate at the same moment; the renames are best-effort and a lost
|
||||
* race costs at most some log lines, never a crash — hence the blanket catch.
|
||||
*/
|
||||
function rotate(): void {
|
||||
const dir = state.dir
|
||||
const file = state.file
|
||||
if (!dir || !file) return
|
||||
try {
|
||||
const oldest = join(dir, `helder.${KEEP}.log`)
|
||||
try { unlinkSync(oldest) } catch { /* wasn't there */ }
|
||||
for (let i = KEEP - 1; i >= 1; i--) {
|
||||
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
|
||||
}
|
||||
renameSync(file, join(dir, 'helder.1.log'))
|
||||
state.size = 0
|
||||
} catch { /* another process rotated first; keep appending */ }
|
||||
}
|
||||
|
||||
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
|
||||
function safeJson(value: unknown): string {
|
||||
const seen = new WeakSet<object>()
|
||||
try {
|
||||
return JSON.stringify(value, (_k, v) => {
|
||||
if (typeof v === 'bigint') return `${v}n`
|
||||
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
if (seen.has(v as object)) return '[Circular]'
|
||||
seen.add(v as object)
|
||||
}
|
||||
return v
|
||||
}) ?? String(value)
|
||||
} catch {
|
||||
return '[unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
|
||||
* (people throw strings), and `cause` is followed so wrapped errors keep their
|
||||
* root cause — usually the line that actually explains the failure.
|
||||
*/
|
||||
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
|
||||
if (e instanceof Error) {
|
||||
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
|
||||
if (e.stack) out.stack = e.stack
|
||||
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
|
||||
return out
|
||||
}
|
||||
return { message: typeof e === 'string' ? e : safeJson(e) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One log line: ISO ts · level · pid · scope · message · context JSON.
|
||||
*
|
||||
* Continuation lines are indented, never bare: a message can carry newlines of
|
||||
* its own (Electron's console warnings do, and so does any stack passed as the
|
||||
* message), and an unindented second line is indistinguishable from a new entry
|
||||
* to both a human skimming the file and to `grep`.
|
||||
*/
|
||||
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
|
||||
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
|
||||
if (ctx === undefined) return head + '\n'
|
||||
return head + ' ' + indent(safeJson(ctx)) + '\n'
|
||||
}
|
||||
|
||||
function indent(s: string): string {
|
||||
return s.replace(/\r?\n/g, '\n ')
|
||||
}
|
||||
|
||||
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
|
||||
if (LEVELS[level] < state.min) return
|
||||
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
|
||||
|
||||
if (state.mirror) {
|
||||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||
fn(line.trimEnd())
|
||||
}
|
||||
|
||||
const file = state.file
|
||||
if (!file) return
|
||||
if (state.size + line.length > MAX_BYTES) rotate()
|
||||
try {
|
||||
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
|
||||
// concurrent appends from sibling project processes don't interleave.
|
||||
appendFileSync(file, line, { encoding: 'utf8' })
|
||||
state.size += Buffer.byteLength(line)
|
||||
} catch { /* disk full / no permission — never let logging break the app */ }
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
|
||||
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
|
||||
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
|
||||
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
|
||||
}
|
||||
|
||||
/** Reset for tests. Not used by the app. */
|
||||
export function _resetLogger(): void {
|
||||
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
|
||||
}
|
||||
28
src/main/notes-service.ts
Normal file
28
src/main/notes-service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Project scratch note: a plain text file at `<project>/.notes.txt`.
|
||||
*
|
||||
* Deliberately not JSON and not part of `.helder/`. It is a note the user
|
||||
* writes by hand, so it must stay readable and editable outside Helder.
|
||||
*/
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { logger } from './logger'
|
||||
|
||||
export const NOTES_FILE = '.notes.txt'
|
||||
|
||||
/** The note's text. Empty string when the project has no note yet. */
|
||||
export async function readNote(root: string): Promise<string> {
|
||||
try {
|
||||
return await readFile(join(root, NOTES_FILE), 'utf8')
|
||||
} catch (err) {
|
||||
// ENOENT is the normal "no note yet" case, anything else is worth knowing.
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn('notes', 'read failed', { root, err: String(err) })
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeNote(root: string, text: string): Promise<void> {
|
||||
await writeFile(join(root, NOTES_FILE), text, 'utf8')
|
||||
}
|
||||
@@ -1,36 +1,43 @@
|
||||
import { basename } from 'node:path'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { dialog, BrowserWindow } from 'electron'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { app, dialog, BrowserWindow } from 'electron'
|
||||
|
||||
/**
|
||||
* One project per window. The root is resolved (in order) from $HELDER_PROJECT,
|
||||
* a directory passed on argv, or the process working directory — then it can be
|
||||
* changed at runtime via the Open Folder dialog.
|
||||
* One project per window. The root is resolved (in order) from $HELDER_PROJECT or
|
||||
* a directory passed on argv; otherwise it starts as `null` — the app then shows
|
||||
* the project launcher (Spotlight / bare launch with no folder). It can be set at
|
||||
* runtime from the launcher or the Open Folder dialog.
|
||||
*
|
||||
* The bare "." that `electron .` passes in dev is intentionally ignored, so dev
|
||||
* launches land on the launcher too (and `basename(".")` never leaks as a name).
|
||||
*/
|
||||
function resolveInitialRoot(): string {
|
||||
function resolveInitialRoot(): string | null {
|
||||
const envRoot = process.env.HELDER_PROJECT
|
||||
if (envRoot && existsSync(envRoot) && statSync(envRoot).isDirectory()) return envRoot
|
||||
const argDir = process.argv.slice(1).find((a) => !a.startsWith('-') && existsSync(a) && safeIsDir(a))
|
||||
if (argDir) return argDir
|
||||
return process.cwd()
|
||||
if (envRoot && safeIsDir(envRoot)) return resolve(envRoot)
|
||||
const argDir = process.argv.slice(1).find((a) => a !== '.' && !a.startsWith('-') && safeIsDir(a))
|
||||
if (argDir) return resolve(argDir)
|
||||
return null
|
||||
}
|
||||
|
||||
function safeIsDir(p: string): boolean {
|
||||
try { return statSync(p).isDirectory() } catch { return false }
|
||||
}
|
||||
|
||||
let root = resolveInitialRoot()
|
||||
let root: string | null = resolveInitialRoot()
|
||||
|
||||
export function getRoot(): string {
|
||||
export function getRoot(): string | null {
|
||||
return root
|
||||
}
|
||||
|
||||
export function getName(): string {
|
||||
return root ? basename(root) || root : 'no project'
|
||||
return root ? basename(root) || root : ''
|
||||
}
|
||||
|
||||
/** Point the window at a project root (absolute) and remember it in recents. */
|
||||
export function setRoot(next: string): void {
|
||||
root = next
|
||||
root = resolve(next)
|
||||
addRecentProject(root).catch(() => {})
|
||||
}
|
||||
|
||||
export async function openDialog(win: BrowserWindow | null): Promise<string | null> {
|
||||
@@ -38,8 +45,41 @@ export async function openDialog(win: BrowserWindow | null): Promise<string | nu
|
||||
? await dialog.showOpenDialog(win, { properties: ['openDirectory'] })
|
||||
: await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
if (!res.canceled && res.filePaths[0]) {
|
||||
root = res.filePaths[0]
|
||||
setRoot(res.filePaths[0])
|
||||
return root
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- recent projects (global, machine-wide — stored in Electron userData) ------
|
||||
export interface RecentProject { path: string; name: string }
|
||||
const MAX_PROJECTS = 20
|
||||
|
||||
function projectsFile(): string {
|
||||
return join(app.getPath('userData'), 'recent-projects.json')
|
||||
}
|
||||
|
||||
export async function getRecentProjects(): Promise<RecentProject[]> {
|
||||
try {
|
||||
const arr = JSON.parse(await readFile(projectsFile(), 'utf8'))
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr
|
||||
.filter((p): p is RecentProject => !!p && typeof p.path === 'string')
|
||||
.filter((p) => existsSync(p.path)) // drop projects that no longer exist
|
||||
.slice(0, MAX_PROJECTS)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRecentProject(path: string): Promise<void> {
|
||||
try {
|
||||
const abs = resolve(path)
|
||||
const list = await getRecentProjects()
|
||||
const entry: RecentProject = { path: abs, name: basename(abs) || abs }
|
||||
const next = [entry, ...list.filter((x) => x.path !== abs)].slice(0, MAX_PROJECTS)
|
||||
await writeFile(projectsFile(), JSON.stringify(next, null, 2) + '\n')
|
||||
} catch {
|
||||
/* userData unwritable — recents just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
|
||||
import type { WebContents } from 'electron'
|
||||
import { getRoot } from './project'
|
||||
import { getConfig } from './config'
|
||||
import { logger } from './logger'
|
||||
|
||||
/**
|
||||
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
|
||||
@@ -19,12 +20,46 @@ let pty: PtyModule | null = null
|
||||
try {
|
||||
pty = require('node-pty') as PtyModule
|
||||
} 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>()
|
||||
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
|
||||
* Their exit is expected, so it must NOT be logged as a warning — a log full of
|
||||
* false alarms is a log nobody reads. */
|
||||
const killing = new Set<number>()
|
||||
let seq = 0
|
||||
|
||||
/**
|
||||
* Env for spawned PTYs. Electron launched from a Homebrew/GUI context leaks
|
||||
* `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes
|
||||
* nvm refuse to load ("nvm is not compatible with the npm_config_prefix
|
||||
* environment variable"). Strip it so the user's shell init runs cleanly.
|
||||
*
|
||||
* A macOS app launched from Spotlight/Finder (launchd GUI context) inherits NO
|
||||
* `LANG`/`LC_*`, so the child shell falls back to the `C`/POSIX locale — not
|
||||
* UTF-8. Anything multibyte the shell or `claude` emits then renders as high-byte
|
||||
* mojibake in xterm. Launching from a terminal (`npm run dev`) inherits the
|
||||
* terminal's UTF-8 locale, which is why dev looks fine and the packaged app does
|
||||
* not. Default a UTF-8 locale when none is set so both paths match.
|
||||
*/
|
||||
function ptyEnv(): { [key: string]: string } {
|
||||
const env = { ...process.env } as { [key: string]: string }
|
||||
delete env.npm_config_prefix
|
||||
delete env.npm_config_globalconfig
|
||||
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
|
||||
env.LANG = 'en_US.UTF-8'
|
||||
}
|
||||
// Same inheritance gap as locale, but for color: a terminal launch leaks
|
||||
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
|
||||
// colors; the GUI-launched packaged app has none, so claude falls back to a
|
||||
// 256/16-color approximation and the same backgrounds shift shade. Match both.
|
||||
if (process.platform !== 'win32' && !env.COLORTERM) {
|
||||
env.COLORTERM = 'truecolor'
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
const configured = getConfig().terminal.shell
|
||||
if (configured) return configured
|
||||
@@ -37,24 +72,46 @@ export function ptyAvailable(): boolean {
|
||||
}
|
||||
|
||||
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 proc = pty.spawn(defaultShell(), [], {
|
||||
name: 'xterm-color',
|
||||
const shell = defaultShell()
|
||||
const ai = getConfig().ai
|
||||
const launchAgent = kind === 'agent' && ai.autoLaunch && process.platform !== 'win32'
|
||||
// For the agent pane we exec the `claude` CLI directly as the shell's command
|
||||
// (`zsh -i -c 'claude'`) instead of typing it into an interactive prompt — `-i`
|
||||
// still sources the user's rc (nvm etc.), but there's no prompt line and no
|
||||
// echoed command cluttering the pane; claude takes over a clean terminal.
|
||||
const args = launchAgent ? ['-i', '-c', ai.command] : []
|
||||
const proc = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
cwd,
|
||||
env: process.env as { [key: string]: string },
|
||||
env: ptyEnv(),
|
||||
})
|
||||
const id = ++seq
|
||||
terms.set(id, proc)
|
||||
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
|
||||
|
||||
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
|
||||
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) })
|
||||
proc.onExit(({ exitCode, signal }) => {
|
||||
terms.delete(id)
|
||||
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
|
||||
// segfault) looks from the UI like "the terminal just went blank" — the exit
|
||||
// code and signal are the only evidence of what actually happened. An exit we
|
||||
// asked for is routine, so only an unrequested one is a warning.
|
||||
const expected = killing.delete(id)
|
||||
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
|
||||
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
|
||||
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
|
||||
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
|
||||
})
|
||||
|
||||
const ai = getConfig().ai
|
||||
if (kind === 'agent' && ai.autoLaunch) {
|
||||
// small delay so the shell prompt is ready before we type the command
|
||||
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
|
||||
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
|
||||
setTimeout(() => { try { proc.write(ai.command + '\r') } catch { /* exited */ } }, 350)
|
||||
}
|
||||
return id
|
||||
@@ -70,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
|
||||
|
||||
export function killPty(id: number): void {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,38 +1,66 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { relative, sep } from 'node:path'
|
||||
import { getConfig } from './config'
|
||||
|
||||
/** Content search via ripgrep; file-name list via `rg --files`. Substring
|
||||
* (fixed-string), smart-case — matching the prototype's search semantics. */
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
let rgPath: string | null = null
|
||||
/** ripgrep is the single source of "what files are in the project": it powers
|
||||
* content search, the file-name list, AND the Explorer tree / content index
|
||||
* (via fs-service) — so gitignore + files.exclude are honored everywhere the
|
||||
* same way. Substring (fixed-string), smart-case search.
|
||||
*
|
||||
* @vscode/ripgrep ships as ESM, so load it with dynamic import() (works for
|
||||
* ESM and CJS) and cache the resolved binary path. */
|
||||
const rgPathPromise: Promise<string | null> = (async () => {
|
||||
try {
|
||||
rgPath = (require('@vscode/ripgrep') as { rgPath: string }).rgPath
|
||||
const mod = await import('@vscode/ripgrep')
|
||||
let p = (mod as { rgPath: string }).rgPath
|
||||
// When packaged the binary is unpacked from the asar; rgPath still points
|
||||
// inside app.asar, so redirect it. No-op in dev (path has no app.asar).
|
||||
if (p) p = p.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
|
||||
return p || null
|
||||
} catch (e) {
|
||||
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
export interface ContentHit { no: number; ln: string; ix: number }
|
||||
export interface ContentGroup { path: string; hits: ContentHit[] }
|
||||
|
||||
const IGNORE_GLOBS = ['node_modules', '.git', 'out', 'dist', 'build', '.cache', 'vendor', 'coverage', '.helder']
|
||||
.flatMap((d) => ['--glob', `!${d}`])
|
||||
/** Always-excluded heavy/noise dirs, on top of gitignore + user excludes. */
|
||||
const BASE_IGNORE = [
|
||||
'node_modules', '.git', 'out', 'dist', 'build', '.cache',
|
||||
'vendor', 'coverage', '.helder', '.next', '.nuxt', '.turbo', '.idea', '.vscode',
|
||||
]
|
||||
|
||||
const MAX_FILES = 400
|
||||
const MAX_LINE = 1000
|
||||
|
||||
export async function rgAvailable(): Promise<boolean> {
|
||||
return !!(await rgPathPromise)
|
||||
}
|
||||
|
||||
/** Glob/ignore args derived from config (files.exclude, files.followGitignore). */
|
||||
function ignoreArgs(): string[] {
|
||||
const cfg = getConfig()
|
||||
const args: string[] = []
|
||||
for (const d of BASE_IGNORE) args.push('--glob', `!${d}`)
|
||||
for (const g of cfg.files.exclude) if (g) args.push('--glob', `!${g}`)
|
||||
if (!cfg.files.followGitignore) args.push('--no-ignore')
|
||||
return args
|
||||
}
|
||||
|
||||
function toRel(root: string, p: string): string {
|
||||
return relative(root, p).split(sep).join('/')
|
||||
}
|
||||
|
||||
export function searchContent(root: string, query: string): Promise<ContentGroup[]> {
|
||||
export async function searchContent(root: string, query: string): Promise<ContentGroup[]> {
|
||||
const rgPath = await rgPathPromise
|
||||
if (!rgPath || query.trim().length < 2) return []
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath || query.trim().length < 2) return resolve([])
|
||||
const child = spawn(rgPath, [
|
||||
'--json', '--fixed-strings', '--smart-case',
|
||||
'--json', '--fixed-strings', '--smart-case', '--hidden',
|
||||
'--max-count', '50', '--max-columns', '2000',
|
||||
...IGNORE_GLOBS, '-e', query, '--', root,
|
||||
...ignoreArgs(), '-e', query, '--', root,
|
||||
])
|
||||
const order: string[] = []
|
||||
const groups = new Map<string, ContentGroup>()
|
||||
@@ -65,10 +93,15 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
|
||||
})
|
||||
}
|
||||
|
||||
export function listFiles(root: string): Promise<string[]> {
|
||||
/** All project files (relative paths), honoring gitignore + excludes. Includes
|
||||
* dotfiles (--hidden) so .env etc. show up unless ignored. Pass `scope` (an
|
||||
* absolute dir inside root) to list only that subtree; paths stay relative to
|
||||
* root either way. */
|
||||
export async function listFiles(root: string, scope?: string): Promise<string[]> {
|
||||
const rgPath = await rgPathPromise
|
||||
if (!rgPath) return []
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath) return resolve([])
|
||||
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
|
||||
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', scope || root])
|
||||
let buf = ''
|
||||
const out: string[] = []
|
||||
let done = false
|
||||
|
||||
@@ -10,25 +10,42 @@ const api = {
|
||||
|
||||
clipboard: {
|
||||
writeText: (text: string) => clipboard.writeText(text),
|
||||
readText: (): string => clipboard.readText(),
|
||||
},
|
||||
|
||||
project: {
|
||||
current: () => ipcRenderer.invoke('project:current'),
|
||||
open: () => ipcRenderer.invoke('project:open'),
|
||||
openPath: (path: string) => ipcRenderer.invoke('project:openPath', path),
|
||||
recent: (): Promise<{ path: string; name: string }[]> => ipcRenderer.invoke('projects:recent'),
|
||||
},
|
||||
|
||||
fs: {
|
||||
tree: () => ipcRenderer.invoke('fs:tree'),
|
||||
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
|
||||
files: () => ipcRenderer.invoke('fs:files'),
|
||||
read: (path: string) => ipcRenderer.invoke('fs:read', path),
|
||||
imageDataUrl: (path: string): Promise<string> => ipcRenderer.invoke('fs:imageDataUrl', path),
|
||||
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
|
||||
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
|
||||
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
|
||||
mkdir: (path: string): Promise<void> => ipcRenderer.invoke('fs:mkdir', path),
|
||||
},
|
||||
|
||||
shell: {
|
||||
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
|
||||
},
|
||||
|
||||
notes: {
|
||||
read: (): Promise<string> => ipcRenderer.invoke('notes:read'),
|
||||
write: (text: string): Promise<void> => ipcRenderer.invoke('notes:write', text),
|
||||
},
|
||||
git: {
|
||||
load: () => ipcRenderer.invoke('git:load'),
|
||||
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
||||
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
|
||||
commit: (message: string) => ipcRenderer.invoke('git:commit', message),
|
||||
push: (): Promise<{ ok: boolean; message: string }> => ipcRenderer.invoke('git:push'),
|
||||
discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
|
||||
},
|
||||
|
||||
@@ -56,11 +73,33 @@ const api = {
|
||||
theme: (): Promise<string> => ipcRenderer.invoke('config:theme'),
|
||||
},
|
||||
|
||||
recent: {
|
||||
get: (): Promise<string[]> => ipcRenderer.invoke('recent:get'),
|
||||
set: (list: string[]): Promise<void> => ipcRenderer.invoke('recent:set', list),
|
||||
},
|
||||
|
||||
search: {
|
||||
content: (query: string) => ipcRenderer.invoke('search:content', query),
|
||||
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
||||
},
|
||||
|
||||
dialog: {
|
||||
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
|
||||
ipcRenderer.invoke('dialog:unsavedClose', path),
|
||||
},
|
||||
|
||||
/** Renderer errors → the main process log file (see renderer/src/log.ts).
|
||||
* `write` is fire-and-forget on purpose: logging must never await, and must
|
||||
* never be able to reject into the very handler that's reporting a failure. */
|
||||
log: {
|
||||
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown): void => {
|
||||
ipcRenderer.send('log:write', level, scope, msg, ctx)
|
||||
},
|
||||
path: (): Promise<string | null> => ipcRenderer.invoke('log:path'),
|
||||
open: (): Promise<void> => ipcRenderer.invoke('log:open'),
|
||||
reveal: (): Promise<void> => ipcRenderer.invoke('log:reveal'),
|
||||
},
|
||||
|
||||
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
|
||||
onProjectChanged: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
@@ -74,6 +113,20 @@ const api = {
|
||||
ipcRenderer.on('config:changed', handler)
|
||||
return () => ipcRenderer.removeListener('config:changed', handler)
|
||||
},
|
||||
|
||||
/** Subscribe to the window entering/leaving fullscreen. Returns an unsubscribe. */
|
||||
onFullscreen: (cb: (on: boolean) => void): (() => void) => {
|
||||
const handler = (_e: unknown, on: boolean): void => cb(on)
|
||||
ipcRenderer.on('window:fullscreen', handler)
|
||||
return () => ipcRenderer.removeListener('window:fullscreen', handler)
|
||||
},
|
||||
|
||||
/** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */
|
||||
onRefresh: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
ipcRenderer.on('view:refresh', handler)
|
||||
return () => ipcRenderer.removeListener('view:refresh', handler)
|
||||
},
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/* Shared icons, FileIcon, GitPanel, FileTree */
|
||||
import React, { Fragment } from 'react'
|
||||
import type { Change, FileNode, GitStatus } from './types'
|
||||
import type { Change, DiffSide, FileNode, GitStatus } from './types'
|
||||
import { HL } from './highlight'
|
||||
|
||||
type SvgProps = React.SVGProps<SVGSVGElement>
|
||||
@@ -12,14 +12,23 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
|
||||
close: (p) => (<svg width="11" height="11" viewBox="0 0 12 12" fill="none" {...p}><path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" /></svg>),
|
||||
copy: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="5" y="5" width="8" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M3 11V3a1 1 0 0 1 1-1h6" stroke="currentColor" strokeWidth="1.3" fill="none" /></svg>),
|
||||
terminal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4l3 3-3 3M8 11h5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
paste: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="3" y="3" width="10" height="11" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3V2.2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V3" stroke="currentColor" strokeWidth="1.3" fill="none" /></svg>),
|
||||
spark: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 1.5l1.6 4.9L14.5 8l-4.9 1.6L8 14.5 6.4 9.6 1.5 8l4.9-1.6L8 1.5z" stroke="currentColor" strokeWidth="1.1" fill="none" strokeLinejoin="round" /></svg>),
|
||||
file: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M9 2v3h3" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),
|
||||
reveal: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none" /></svg>),
|
||||
folder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2 4.5h4l1.3 1.5H14V13H2V4.5z" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round" /></svg>),
|
||||
diff: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M4 2v8M4 12.5v1.5M2 4h4M2 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /><path d="M12 14V6M12 3.5V2M10 12h4M10 8h4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
|
||||
plus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M7 2.5v9M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
|
||||
minus: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7h9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /></svg>),
|
||||
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
|
||||
note: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><path d="M3.5 2.5h9v11h-9v-11z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
|
||||
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
|
||||
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
|
||||
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 => (
|
||||
@@ -30,8 +39,14 @@ export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
||||
|
||||
export const FolderIcon = ({ open }: { open: boolean }): React.ReactElement => (
|
||||
<svg className="folder-ic" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||
<path d={open ? 'M1.5 4.5h4l1.2 1.4H14V13H2V4.5z' : 'M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z'}
|
||||
fill={open ? 'rgba(122,131,140,.18)' : 'rgba(122,131,140,.12)'} stroke="currentColor" strokeWidth="1.1" />
|
||||
{open ? (
|
||||
<Fragment>
|
||||
<path d="M2 12.5V4.5h3.6l1.2 1.4h6.7V7.4" fill="rgba(122,131,140,.16)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
<path d="M1.4 12.6l1.9-5.1h11.4l-1.9 5.1H1.4z" fill="rgba(122,131,140,.22)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
</Fragment>
|
||||
) : (
|
||||
<path d="M1.5 4.5h4l1.2 1.4H14V13H1.5V4.5z" fill="rgba(122,131,140,.12)" stroke="currentColor" strokeWidth="1.1" strokeLinejoin="round" />
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -41,52 +56,57 @@ export function FileIcon({ path }: { path: string }): React.ReactElement {
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
path: string
|
||||
kind: 'editor' | 'dir' | 'file' | 'git'
|
||||
staged?: boolean
|
||||
sel?: { start: number; end: number }
|
||||
line?: number
|
||||
code?: string
|
||||
}
|
||||
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
|
||||
|
||||
/* ============ Git / Source Control panel ============ */
|
||||
function GitRow({ c, staged, activePath, onOpen, onContext, onToggleStage }: {
|
||||
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
|
||||
c: Change
|
||||
staged: boolean
|
||||
activePath: string | null
|
||||
/** Which half the open tab is showing, so only that row lights up. */
|
||||
activeSide: DiffSide | null
|
||||
ctxPath: string | null
|
||||
kbdId: string | null
|
||||
showDir: boolean
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
onToggleStage: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
const staged = c.staged
|
||||
const side: DiffSide = staged ? 'staged' : 'unstaged'
|
||||
const name = c.path.split('/').pop()
|
||||
const dir = c.path.split('/').slice(0, -1).join('/')
|
||||
const dirShown = showDir && !!dir
|
||||
const isActive = activePath === c.path && (!activeSide || activeSide === side)
|
||||
return (
|
||||
<div className={'git-row' + (activePath === c.path ? ' active' : '')}
|
||||
onClick={() => onOpen(c.path, { diff: true })}
|
||||
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
|
||||
data-row-id={c.id}
|
||||
onClick={() => onOpen(c.path, { diff: true, side })}
|
||||
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
|
||||
title={c.path}>
|
||||
<span className={'git-stat ' + c.status}>{c.status}</span>
|
||||
<FileIcon path={c.path} />
|
||||
<span className={'git-name' + (c.deleted ? ' del' : '')}>{name}</span>
|
||||
{dir && <span className="git-dir">{dir}/</span>}
|
||||
<button className="git-act" title={staged ? 'Unstage changes' : 'Stage changes'}
|
||||
{dirShown && <span className="git-dir">{dir}/</span>}
|
||||
<button className={'git-act' + (dirShown ? '' : ' push')} title={staged ? 'Unstage changes' : 'Stage changes'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleStage(c.path) }}>
|
||||
{staged ? Icon.minus() : Icon.plus()}
|
||||
</button>
|
||||
<span className="git-delta">
|
||||
{c.add > 0 && <span className="a">+{c.add}</span>}
|
||||
{c.del > 0 && <span className="d">-{c.del}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath }: {
|
||||
export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: {
|
||||
branch: string
|
||||
changes: Change[]
|
||||
staged: Set<string>
|
||||
committed: Set<string>
|
||||
commitMsg: string
|
||||
setCommitMsg: (v: string) => void
|
||||
@@ -95,32 +115,29 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
||||
onStageAll: () => void
|
||||
onUnstageAll: () => void
|
||||
onCommit: () => void
|
||||
onPush: () => void
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
activeSide: DiffSide | null
|
||||
ctxPath: string | null
|
||||
kbdId: string | null
|
||||
showDir: boolean
|
||||
}): React.ReactElement {
|
||||
const visible = changes.filter((c) => !committed.has(c.path))
|
||||
const stagedList = visible.filter((c) => staged.has(c.path))
|
||||
const changesList = visible.filter((c) => !staged.has(c.path))
|
||||
const stagedList = visible.filter((c) => c.staged)
|
||||
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 canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="phead">
|
||||
{Icon.branch()}<span>Source Control</span>
|
||||
<span className="ct">{visible.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="commit-box">
|
||||
<textarea className="commit-input" rows={1} value={commitMsg} spellCheck={false}
|
||||
placeholder="Message (⌘↵ to commit)"
|
||||
placeholder="Shift+Enter to commit"
|
||||
onChange={(e) => setCommitMsg(e.target.value)}
|
||||
onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && canCommit) { e.preventDefault(); onCommit() } }} />
|
||||
<button className="commit-btn" disabled={!canCommit} onClick={onCommit}
|
||||
title={canCommit ? 'Commit staged changes' : 'Stage files and write a message to commit'}>
|
||||
{Icon.check()}<span>Commit{stagedList.length ? ' ' + stagedList.length : ''}</span>
|
||||
</button>
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
|
||||
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
|
||||
</div>
|
||||
|
||||
<div className="git-body">
|
||||
@@ -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>}
|
||||
</div>
|
||||
{stagedList.length > 0 ? stagedList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={true} activePath={activePath}
|
||||
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
|
||||
)) : (
|
||||
<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>}
|
||||
</div>
|
||||
{changesList.length > 0 ? changesList.map((c) => (
|
||||
<GitRow key={c.path} c={c} staged={false} activePath={activePath}
|
||||
<GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
|
||||
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
|
||||
)) : (
|
||||
<div className="git-none">All changes staged</div>
|
||||
@@ -167,7 +184,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
||||
}
|
||||
|
||||
/* ============ 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
|
||||
depth: number
|
||||
openDirs: Set<string>
|
||||
@@ -175,8 +192,11 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
ctxPath: string | null
|
||||
kbdPath: string | null
|
||||
changeMap: Record<string, GitStatus>
|
||||
committed: Set<string>
|
||||
showHidden: boolean
|
||||
}): React.ReactElement {
|
||||
const pad = 10 + depth * 13
|
||||
if (node.type === 'dir') {
|
||||
@@ -184,7 +204,8 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
||||
return (
|
||||
<Fragment>
|
||||
{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)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
|
||||
<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>
|
||||
</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}
|
||||
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>
|
||||
)
|
||||
}
|
||||
const status = committed && committed.has(node.path) ? null : changeMap[node.path]
|
||||
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 }}
|
||||
data-row-path={node.path}
|
||||
onClick={() => onOpen(node.path)}
|
||||
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
|
||||
title={node.path}>
|
||||
@@ -215,25 +239,24 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
|
||||
)
|
||||
}
|
||||
|
||||
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, changeMap, committed }: {
|
||||
export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activePath, ctxPath, kbdPath, changeMap, committed, showHidden }: {
|
||||
tree: FileNode
|
||||
openDirs: Set<string>
|
||||
toggleDir: (path: string) => void
|
||||
onOpen: OpenFile
|
||||
onContext: OnContext
|
||||
activePath: string | null
|
||||
ctxPath: string | null
|
||||
kbdPath: string | null
|
||||
changeMap: Record<string, GitStatus>
|
||||
committed: Set<string>
|
||||
showHidden: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Fragment>
|
||||
<div className="phead">
|
||||
<span>Explorer</span>
|
||||
<span style={{ marginLeft: 'auto', color: 'var(--fg-3)', textTransform: 'none', letterSpacing: 0, fontFamily: 'var(--mono)', fontSize: 10.5 }}>{tree.name}</span>
|
||||
</div>
|
||||
<div className="tree-body">
|
||||
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
|
||||
onOpen={onOpen} onContext={onContext} activePath={activePath} changeMap={changeMap} committed={committed} />
|
||||
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* same original/updated text pair per changed file, so keep buildDiff()'s
|
||||
* output shape. */
|
||||
import type { Change, Diff, FileNode, Project } from './types'
|
||||
import { rowId } from './types'
|
||||
import { buildDiff } from './diff'
|
||||
|
||||
// ---- working-tree (current / updated) file contents ----------------
|
||||
@@ -651,7 +652,8 @@ const changes: Change[] = changeDefs.map((c) => {
|
||||
original: orig,
|
||||
updated: upd,
|
||||
})
|
||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }
|
||||
// Mock rows are all unstaged here; project.tsx flips the staged ones over.
|
||||
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: false, id: rowId(c.path, false) }
|
||||
})
|
||||
|
||||
export const PROJECT: Project = {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
|
||||
import React, { Fragment, useEffect, useMemo, useRef } from 'react'
|
||||
import type { Diff, ViewLine } from './types'
|
||||
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Diff, DiffSide, ViewLine } from './types'
|
||||
import { rowId } from './types'
|
||||
import { useProject } from './project'
|
||||
import { HL } from './highlight'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import type { OnContext } from './components'
|
||||
|
||||
export interface Cursor { path: string; line: number; col: 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 {
|
||||
let el: HTMLElement | null = node && node.nodeType === 3 ? (node.parentElement as HTMLElement) : (node as HTMLElement | null)
|
||||
@@ -16,8 +18,6 @@ function climbToLine(node: Node | null): HTMLElement | null {
|
||||
return el || null
|
||||
}
|
||||
|
||||
interface ResolvedTab { path: string; changed: boolean; modeLabel: string; dirty?: boolean }
|
||||
|
||||
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
|
||||
* scroll-synced line-number gutter. Live highlighting while typing. */
|
||||
function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||
@@ -29,6 +29,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||
}): React.ReactElement {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const gutterRef = useRef<HTMLDivElement>(null)
|
||||
const tabSize = useProject().config.editor.tabSize
|
||||
const html = useMemo(() => HL.hlText(text, lang), [text, lang])
|
||||
const count = useMemo(() => text.split('\n').length, [text])
|
||||
|
||||
@@ -47,6 +48,48 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||
if (top < s.scrollTop) s.scrollTop = top - 20
|
||||
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 {
|
||||
e.preventDefault()
|
||||
const ta = e.currentTarget
|
||||
@@ -54,7 +97,8 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||
const info: Parameters<OnContext>[1] = { path, kind: 'editor', line: startLine }
|
||||
if (ta.selectionEnd > ta.selectionStart) {
|
||||
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)
|
||||
}
|
||||
@@ -68,48 +112,51 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
|
||||
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
|
||||
<div className="ce-inner">
|
||||
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
|
||||
wrap="off"
|
||||
wrap="off" style={{ tabSize }}
|
||||
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
|
||||
onClick={(e) => ensureCaretVisible(e.currentTarget)}
|
||||
onContextMenu={handleContext} />
|
||||
<pre className="ce-pre" aria-hidden dangerouslySetInnerHTML={{ __html: html + '\n' }} />
|
||||
<pre className="ce-pre" aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html + '\n' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorTabs({ tabs, active, onActivate, onClose }: {
|
||||
tabs: ResolvedTab[]
|
||||
active: string | null
|
||||
onActivate: (path: string) => void
|
||||
onClose: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current && ref.current.querySelector('.tab.active')
|
||||
if (el) el.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
}, [active])
|
||||
/* 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="tabs" ref={ref}>
|
||||
{tabs.map((t) => {
|
||||
const name = t.path.split('/').pop()
|
||||
return (
|
||||
<div key={t.path}
|
||||
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirty' : '')}
|
||||
onClick={() => onActivate(t.path)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }}
|
||||
title={t.path}>
|
||||
<FileIcon path={t.path} />
|
||||
<span className="tname">{name}</span>
|
||||
{t.changed && <span className="tab-mode">{t.modeLabel}</span>}
|
||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
|
||||
{Icon.close()}
|
||||
</span>
|
||||
<div 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)' }}>Can’t preview this image</div></div>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -168,6 +215,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
|
||||
if (el) { setCursor({ path, line: +el.dataset.line!, col: caretCol(sel) }); setSelection(null) }
|
||||
}
|
||||
}
|
||||
// Join the source lines covered by a selection — the code-block payload for "Pass on selection".
|
||||
function codeForRange(s: number, en: number): string {
|
||||
return lines.filter((l) => l.no != null && l.no >= s && l.no <= en).map((l) => l.text).join('\n')
|
||||
}
|
||||
function handleContext(e: React.MouseEvent): void {
|
||||
e.preventDefault()
|
||||
const sel = window.getSelection()
|
||||
@@ -176,9 +227,10 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
|
||||
const f = sel && sel.focusNode && climbToLine(sel.focusNode)
|
||||
if (sel && !sel.isCollapsed && a && f && +a.dataset.line! !== +f.dataset.line!) {
|
||||
const s = Math.min(+a.dataset.line!, +f.dataset.line!), en = Math.max(+a.dataset.line!, +f.dataset.line!)
|
||||
info.sel = { start: s, end: en }; info.line = s
|
||||
info.sel = { start: s, end: en }; info.line = s; info.code = codeForRange(s, en)
|
||||
} else if (selection && selection.path === path && selection.start !== selection.end) {
|
||||
info.sel = { start: selection.start, end: selection.end }; info.line = selection.start
|
||||
info.code = codeForRange(selection.start, selection.end)
|
||||
} else {
|
||||
let no: number | null = null
|
||||
const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null
|
||||
@@ -215,7 +267,9 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
|
||||
)
|
||||
}
|
||||
|
||||
/* Build the line descriptors for a given mode. */
|
||||
/* Build the line descriptors for a given mode. The caller picks which diff to
|
||||
* pass: Original and Actual always get the file-level HEAD-vs-disk pair, while
|
||||
* Diff gets the pair of the git row you clicked. */
|
||||
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
|
||||
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 }
|
||||
@@ -225,19 +279,23 @@ function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined)
|
||||
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
|
||||
}
|
||||
|
||||
const SEGMENTS: { id: Mode; label: string }[] = [
|
||||
{ id: 'original', label: 'Original' },
|
||||
{ id: 'updated', label: 'Updated' },
|
||||
{ id: 'diff', label: 'Diff' },
|
||||
]
|
||||
/** View options available for a file, given whether it has a diff and whether
|
||||
* it's markdown. Unchanged files only have the plain editable "Code" view; a
|
||||
* changed file gets the Updated/Original/Diff trio; markdown adds "Preview". */
|
||||
function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label: string }[] {
|
||||
const segs: { id: Mode; label: string }[] = hasDiff
|
||||
? [{ id: 'updated', label: 'Actual' }, { id: 'original', label: 'Original' }, { id: 'diff', label: 'Diff' }]
|
||||
: [{ id: 'code', label: isMarkdown ? 'Actual' : 'Code' }]
|
||||
if (isMarkdown) segs.push({ id: 'preview', label: 'Preview' })
|
||||
return segs
|
||||
}
|
||||
|
||||
export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
||||
tabs: ResolvedTab[]
|
||||
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
|
||||
active: string | null
|
||||
mode: Mode
|
||||
/** Which git row opened this tab. Only Diff and Split follow it. */
|
||||
side: DiffSide | null
|
||||
setMode: (m: Mode) => void
|
||||
onActivate: (path: string) => void
|
||||
onClose: (path: string) => void
|
||||
onContext: OnContext
|
||||
onSplit: (path: string) => void
|
||||
splitOpen: boolean
|
||||
@@ -249,64 +307,109 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
|
||||
onEdit: (text: string) => void
|
||||
}): React.ReactElement {
|
||||
const PROJECT = useProject()
|
||||
const tab = tabs.find((t) => t.path === active)
|
||||
const tab = active ? { path: active } : null
|
||||
const isImage = tab ? HL.isImage(tab.path) : false
|
||||
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
|
||||
// Original / Actual always show the whole file: HEAD vs disk.
|
||||
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 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
|
||||
if (tab) {
|
||||
if (change && diff) built = buildLines(effMode, diff, PROJECT.files[tab.path])
|
||||
if (tab && effMode !== 'preview') {
|
||||
if (hasDiff) built = buildLines(effMode, shown, 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 emptyUpdated = effMode === 'updated' && built && built.lines.length === 0
|
||||
const emptyOriginal = effMode === 'original' && built && built.lines.length === 0
|
||||
// Editable in the live-buffer modes; Original/Diff stay read-only review views.
|
||||
// Keyed off the git status, not the line count: an empty file that still exists
|
||||
// (a just-created one, or one emptied by hand) has zero lines too, and must get
|
||||
// the editor rather than the "deleted" placeholder.
|
||||
const emptyUpdated = 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'
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EditorTabs tabs={tabs} active={active} onActivate={onActivate} onClose={onClose} />
|
||||
{!tab ? (
|
||||
<div className="empty-ed">
|
||||
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
|
||||
<div className="big">No file open</div>
|
||||
{/* Only what works with no file open — Copy reference and Pass on to
|
||||
Agent need a file, so they are not advertised here. */}
|
||||
<div className="klist">
|
||||
<div><span>Open folder</span><kbd>⌘O</kbd></div>
|
||||
<div><span>Recent projects</span><kbd>⇧⌘O</kbd></div>
|
||||
<div><span>Search files & content</span><kbd>⌘F</kbd></div>
|
||||
<div><span>Copy reference</span><kbd>right-click</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 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">
|
||||
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span>
|
||||
{change.add > 0 && <span className="a">+{change.add}</span>}
|
||||
{change.del > 0 && <span className="d">−{change.del}</span>}
|
||||
{change ? (
|
||||
<Fragment>
|
||||
<span className={'git-stat ' + fileStatus} style={{ width: 'auto' }}>{statusWord}</span>
|
||||
{!!shown && shown.add > 0 && <span className="a">+{shown.add}</span>}
|
||||
{!!shown && shown.del > 0 && <span className="d">−{shown.del}</span>}
|
||||
{/* Only ambiguous when the file is staged AND edited again: say
|
||||
which pair the diff is comparing. */}
|
||||
{bothSides && effMode === 'diff' && (
|
||||
<span className="db-side">{side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}</span>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<span className="db-lang">{isImage ? 'Image' : HL.langLabel(tab.path)}</span>
|
||||
)}
|
||||
{!isImage && (
|
||||
<div className="seg">
|
||||
{SEGMENTS.map((s) => (
|
||||
{segments.map((s) => (
|
||||
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
|
||||
))}
|
||||
{hasDiff && (
|
||||
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
) : 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>
|
||||
) : editable ? (
|
||||
<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}
|
||||
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
|
||||
)}
|
||||
@@ -317,20 +420,24 @@ export function Editor({ tabs, active, mode, setMode, onActivate, onClose, onCon
|
||||
}
|
||||
|
||||
/* Full-screen side-by-side split view */
|
||||
export function SplitView({ path, onClose, onContext }: {
|
||||
export function SplitView({ path, side, onClose, onContext }: {
|
||||
path: string
|
||||
/** Which git row opened this file. Split compares that row's pair. */
|
||||
side: DiffSide | null
|
||||
onClose: () => void
|
||||
onContext: OnContext
|
||||
}): React.ReactElement {
|
||||
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 leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null)
|
||||
const lock = useRef(false)
|
||||
const change = PROJECT.changes.find((c) => c.path === path)
|
||||
const splitStatus = diff.deleted ? 'D' : diff.added ? 'A' : 'M'
|
||||
const bothSides = !!PROJECT.rowDiffs[rowId(path, true)] && !!PROJECT.rowDiffs[rowId(path, false)]
|
||||
|
||||
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path])
|
||||
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.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, side])
|
||||
|
||||
function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void {
|
||||
if (lock.current || !from || !to) return
|
||||
@@ -352,9 +459,10 @@ export function SplitView({ path, onClose, onContext }: {
|
||||
<div className="split-head">
|
||||
<FileIcon path={path} />
|
||||
<span className="sh-name">{path}</span>
|
||||
{change && <span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified'}</span>}
|
||||
{change && change.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{change.add}</span>}
|
||||
{change && change.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>−{change.del}</span>}
|
||||
{change && <span className={'git-stat ' + splitStatus} style={{ width: 'auto' }}>{splitStatus === 'A' ? 'Added' : splitStatus === 'D' ? 'Deleted' : 'Modified'}</span>}
|
||||
{diff.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{diff.add}</span>}
|
||||
{diff.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>−{diff.del}</span>}
|
||||
{bothSides && <span className="db-side">{side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}</span>}
|
||||
<button className="split-exit" onClick={onClose}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
Collapse <kbd>Esc</kbd>
|
||||
@@ -365,7 +473,7 @@ export function SplitView({ path, onClose, onContext }: {
|
||||
<div className="split-label">Original <span>before</span></div>
|
||||
<div className="editor" ref={leftRef} onScroll={() => sync(leftRef.current, rightRef.current)} onContextMenu={ctx}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.l ? row.l.no : undefined} className={'ln-row' + (row.l && row.l.mark === 'del' ? ' bar-del' : '') + (!row.l ? ' empty' : '')}>
|
||||
<div key={i} data-line={row.l ? row.l.no : undefined} className={'ln-row' + (row.l && row.l.mark === 'del' ? ' del bar-del' : '') + (!row.l ? ' empty' : '')}>
|
||||
<span className="ln-gutter">{row.l ? row.l.no : ''}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.l ? leftHtml[i] : '' }} />
|
||||
</div>
|
||||
@@ -376,7 +484,7 @@ export function SplitView({ path, onClose, onContext }: {
|
||||
<div className="split-label">Updated <span>after</span></div>
|
||||
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
|
||||
{diff.split.map((row, i) => (
|
||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' bar-add' : '') + (!row.r ? ' empty' : '')}>
|
||||
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>
|
||||
<span className="ln-gutter">{row.r ? row.r.no : ''}</span>
|
||||
<span className="ln-code" dangerouslySetInnerHTML={{ __html: row.r ? rightHtml[i] : '' }} />
|
||||
</div>
|
||||
|
||||
32
src/renderer/src/env.d.ts
vendored
32
src/renderer/src/env.d.ts
vendored
@@ -11,22 +11,37 @@ interface GitChangeRaw {
|
||||
|
||||
interface HelderBridge {
|
||||
platform: string
|
||||
clipboard: { writeText: (text: string) => void }
|
||||
clipboard: { writeText: (text: string) => void; readText: () => string }
|
||||
project: {
|
||||
current: () => Promise<{ root: string | null; name: string }>
|
||||
open: () => Promise<{ root: string | null; name: string }>
|
||||
openPath: (path: string) => Promise<{ root: string | null; name: string }>
|
||||
recent: () => Promise<{ path: string; name: string }[]>
|
||||
}
|
||||
fs: {
|
||||
tree: () => Promise<FileNode | null>
|
||||
readDir: (path: string) => Promise<FileNode[]>
|
||||
files: () => Promise<Record<string, string>>
|
||||
read: (path: string) => Promise<string>
|
||||
imageDataUrl: (path: string) => Promise<string>
|
||||
write: (path: string, content: string) => Promise<void>
|
||||
delete: (path: string) => Promise<void>
|
||||
create: (path: string) => Promise<void>
|
||||
mkdir: (path: string) => Promise<void>
|
||||
}
|
||||
shell: {
|
||||
reveal: (path: string) => void
|
||||
}
|
||||
notes: {
|
||||
read: () => Promise<string>
|
||||
write: (text: string) => Promise<void>
|
||||
}
|
||||
git: {
|
||||
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
||||
stage: (paths: string[]) => Promise<void>
|
||||
unstage: (paths: string[]) => Promise<void>
|
||||
commit: (message: string) => Promise<void>
|
||||
push: () => Promise<{ ok: boolean; message: string }>
|
||||
discard: (paths: string[]) => Promise<void>
|
||||
}
|
||||
pty: {
|
||||
@@ -42,12 +57,27 @@ interface HelderBridge {
|
||||
get: () => Promise<HelderConfig>
|
||||
theme: () => Promise<string>
|
||||
}
|
||||
recent: {
|
||||
get: () => Promise<string[]>
|
||||
set: (list: string[]) => Promise<void>
|
||||
}
|
||||
search: {
|
||||
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
||||
files: () => Promise<string[]>
|
||||
}
|
||||
dialog: {
|
||||
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
|
||||
}
|
||||
log: {
|
||||
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown) => void
|
||||
path: () => Promise<string | null>
|
||||
open: () => Promise<void>
|
||||
reveal: () => Promise<void>
|
||||
}
|
||||
onFullscreen: (cb: (on: boolean) => void) => () => void
|
||||
onProjectChanged: (cb: () => void) => () => void
|
||||
onConfigChanged: (cb: () => void) => () => void
|
||||
onRefresh: (cb: () => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
53
src/renderer/src/error-boundary.tsx
Normal file
53
src/renderer/src/error-boundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
import { rlog } from './log'
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
stack: string | null
|
||||
}
|
||||
|
||||
/** Catches render-time errors anywhere in the tree and shows a dark, recoverable
|
||||
* panel instead of a blank window. */
|
||||
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
|
||||
state: State = { error: null, stack: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error, stack: null }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
// The component stack is the part that says WHICH panel blew up — it exists
|
||||
// only here, so it has to be logged now or it's gone.
|
||||
rlog.error('react', 'render error', error, { componentStack: info.componentStack })
|
||||
this.setState({ stack: info.componentStack ?? null })
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error, stack } = this.state
|
||||
if (!error) return this.props.children
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: 14, background: 'var(--bg-0)', color: 'var(--fg-1)', fontFamily: 'var(--ui)', padding: 40, textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 15, color: 'var(--fg-0)' }}>Something went wrong</div>
|
||||
<pre style={{
|
||||
maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left',
|
||||
fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)',
|
||||
background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap',
|
||||
}}>{(error.stack || error.message) + (stack ? '\n' + stack : '')}</pre>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => location.reload()} style={{
|
||||
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
|
||||
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
|
||||
}}>Reload</button>
|
||||
{/* The panel shows this one error; the log has what led up to it. */}
|
||||
<button onClick={() => window.helder?.log.open()} style={{
|
||||
background: 'var(--bg-3)', color: 'var(--fg-1)', border: '1px solid var(--border-2)', borderRadius: 7,
|
||||
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
|
||||
}}>Open Log</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
12
src/renderer/src/fuzzy.ts
Normal file
12
src/renderer/src/fuzzy.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/** Subsequence fuzzy match. Returns the matched character indices in `str`
|
||||
* (in order), or null when `q` is not a subsequence of `str`. Case-insensitive. */
|
||||
export function fuzzy(q: string, str: string): number[] | null {
|
||||
q = q.toLowerCase()
|
||||
const s = str.toLowerCase()
|
||||
let i = 0
|
||||
const idx: number[] = []
|
||||
for (let j = 0; j < s.length && i < q.length; j++) {
|
||||
if (s[j] === q[i]) { idx.push(j); i++ }
|
||||
}
|
||||
return i === q.length ? idx : null
|
||||
}
|
||||
@@ -34,6 +34,12 @@ function ext(path: string): string {
|
||||
return i >= 0 ? base.slice(i + 1).toLowerCase() : ''
|
||||
}
|
||||
|
||||
// Files the viewer renders as a picture (<img>) rather than as text/code.
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng', 'jfif'])
|
||||
function isImage(path: string): boolean {
|
||||
return IMAGE_EXT.has(ext(path))
|
||||
}
|
||||
|
||||
function langFor(path: string): string | null {
|
||||
return EXT_LANG[ext(path)] || null
|
||||
}
|
||||
@@ -112,4 +118,4 @@ function iconFor(path: string): IconMeta {
|
||||
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 }
|
||||
|
||||
72
src/renderer/src/launcher.tsx
Normal file
72
src/renderer/src/launcher.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Project launcher — shown when Helder starts without a project (Spotlight / bare
|
||||
* launch). Lists the recent projects (max 20, newest first); the first row opens a
|
||||
* folder picker for a new project. Keyboard: ⌘↑/⌘↓ (or plain arrows) move the
|
||||
* selection, ↵ opens it — same model as the recent-files navigator. */
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Icon } from './components'
|
||||
import type { RecentProject } from './project'
|
||||
|
||||
export function ProjectLauncher({ recents, onOpenNew, onOpenPath }: {
|
||||
recents: RecentProject[]
|
||||
onOpenNew: () => void
|
||||
onOpenPath: (path: string) => void
|
||||
}): React.ReactElement {
|
||||
const [sel, setSel] = useState(0)
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// rows = [new project, ...recents]; total selectable count
|
||||
const count = recents.length + 1
|
||||
|
||||
function activate(i: number): void {
|
||||
if (i <= 0) onOpenNew()
|
||||
else if (recents[i - 1]) onOpenPath(recents[i - 1].path)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, count - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); activate(selRef.current) }
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [count, recents])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.lp-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="launcher">
|
||||
<div className="launcher-drag" />
|
||||
<div className="launcher-card">
|
||||
<div className="lp-head">
|
||||
{Icon.spark({ width: 22, height: 22, style: { color: 'var(--accent)' } })}
|
||||
<div className="lp-title"><b>Helder</b><span>Open a project to begin</span></div>
|
||||
</div>
|
||||
<div className="lp-list" ref={listRef}>
|
||||
<div className={'lp-row lp-new' + (sel === 0 ? ' sel' : '')}
|
||||
onMouseEnter={() => setSel(0)} onClick={() => activate(0)}>
|
||||
<span className="lp-ic">{Icon.plus()}</span>
|
||||
<div className="lp-txt"><span className="lp-name">Open new project…</span><span className="lp-path">Choose a folder</span></div>
|
||||
<kbd>↵</kbd>
|
||||
</div>
|
||||
{recents.length > 0 && <div className="lp-sec">Recent</div>}
|
||||
{recents.map((p, i) => {
|
||||
const idx = i + 1
|
||||
return (
|
||||
<div key={p.path} className={'lp-row' + (sel === idx ? ' sel' : '')} title={p.path}
|
||||
onMouseEnter={() => setSel(idx)} onClick={() => activate(idx)}>
|
||||
<span className="lp-ic">{Icon.reveal()}</span>
|
||||
<div className="lp-txt"><span className="lp-name">{p.name}</span><span className="lp-path">{p.path}</span></div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="lp-foot"><kbd>⌘↓</kbd> <kbd>⌘↑</kbd> navigate · <kbd>↵</kbd> open</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/renderer/src/log.ts
Normal file
75
src/renderer/src/log.ts
Normal 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}`,
|
||||
})
|
||||
}
|
||||
@@ -13,11 +13,18 @@ import './styles.css'
|
||||
|
||||
import { App } from './App'
|
||||
import { ProjectProvider } from './project'
|
||||
import { ErrorBoundary } from './error-boundary'
|
||||
import { installErrorLogging } from './log'
|
||||
|
||||
// Before the first render, so an exception during mount is already captured.
|
||||
installErrorLogging()
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
174
src/renderer/src/markdown.ts
Normal file
174
src/renderer/src/markdown.ts
Normal 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')
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */
|
||||
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useProject } from './project'
|
||||
import type { RecentProject } from './project'
|
||||
import { fuzzy } from './fuzzy'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import type { OpenFile } from './components'
|
||||
|
||||
@@ -12,43 +14,65 @@ export interface MenuItem {
|
||||
kbd?: string
|
||||
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 }
|
||||
|
||||
/** Build the exact text inserted into the agent for a "Pass on …" action.
|
||||
* - Plain reference / name → `note => thing` (or just `thing` with no note).
|
||||
* - Selected code → `note ref` followed by the code in a fenced block. */
|
||||
export function buildPass(note: string, ref: string, code?: string): string {
|
||||
const n = note.trim()
|
||||
if (code != null) {
|
||||
const head = (n ? n + ' ' : '') + ref
|
||||
return head + '\n```\n' + code + '\n```'
|
||||
}
|
||||
return n ? `${n} => ${ref}` : ref
|
||||
}
|
||||
interface ContentHit { no: number; ln: string; ix: number }
|
||||
interface ContentGroup { path: string; hits: ContentHit[] }
|
||||
|
||||
export function fuzzy(q: string, str: string): number[] | null {
|
||||
q = q.toLowerCase(); const s = str.toLowerCase()
|
||||
let i = 0; const idx: number[] = []
|
||||
for (let j = 0; j < s.length && i < q.length; j++) {
|
||||
if (s[j] === q[i]) { idx.push(j); i++ }
|
||||
}
|
||||
return i === q.length ? idx : null
|
||||
}
|
||||
|
||||
function Highlight({ text, idx }: { text: string; idx: number[] | null }): React.ReactElement {
|
||||
if (!idx || !idx.length) return <span>{text}</span>
|
||||
const set = new Set(idx)
|
||||
return <span>{text.split('').map((ch, i) => set.has(i) ? <b key={i}>{ch}</b> : <Fragment key={i}>{ch}</Fragment>)}</span>
|
||||
}
|
||||
|
||||
export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
/** A path is hidden if any of its segments is a dotfile/dotfolder (e.g. `.env`,
|
||||
* `src/.cache/x`). Used to exclude hidden entries from search when the toggle is
|
||||
* off — mirrors the Explorer tree filter. */
|
||||
function isHiddenPath(p: string): boolean {
|
||||
return p.split('/').some((seg) => seg.startsWith('.'))
|
||||
}
|
||||
|
||||
export function SearchModal({ initialQuery, onOpen, onOpenAt, onClose, changeSet, activePath, activeText, showHidden }: {
|
||||
initialQuery?: string
|
||||
onOpen: OpenFile
|
||||
onOpenAt: (path: string, line: number) => void
|
||||
onClose: () => void
|
||||
changeSet: Set<string>
|
||||
activePath?: string | null
|
||||
activeText?: string
|
||||
showHidden?: boolean
|
||||
}): React.ReactElement {
|
||||
const PROJECT = useProject()
|
||||
const bridge = window.helder
|
||||
const [q, setQ] = useState('')
|
||||
const [q, setQ] = useState(() => initialQuery ?? '')
|
||||
const [sel, setSel] = useState(0)
|
||||
const [fileSel, setFileSel] = useState(0)
|
||||
const [inFileSel, setInFileSel] = useState(0)
|
||||
const hasInFile = !!activePath
|
||||
type Col = 'infile' | 'content' | 'files'
|
||||
const cols: Col[] = hasInFile ? ['infile', 'content', 'files'] : ['content', 'files']
|
||||
const [col, setCol] = useState<Col>('content') // active result column (⌘← / ⌘→ cycle)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const inFileRef = useRef<HTMLDivElement>(null)
|
||||
const leftRef = useRef<HTMLDivElement>(null)
|
||||
const rightRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// file-name list: ripgrep `--files` when available, else the in-memory index keys
|
||||
const [allPaths, setAllPaths] = useState<string[]>(() => (bridge ? [] : Object.keys(PROJECT.files)))
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current.focus()
|
||||
if (inputRef.current) { inputRef.current.focus(); inputRef.current.select() } // select seed so typing replaces it
|
||||
if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
|
||||
}, [])
|
||||
|
||||
@@ -79,12 +103,32 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
return
|
||||
}, [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)
|
||||
const files = useMemo(() => {
|
||||
const term = q.trim()
|
||||
if (!term) return []
|
||||
const out: { path: string; idx: number[] | null; rank: number; pos: number }[] = []
|
||||
for (const p of allPaths) {
|
||||
if (!showHidden && isHiddenPath(p)) continue
|
||||
const name = p.split('/').pop() as string
|
||||
const ni = fuzzy(term, name)
|
||||
if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue }
|
||||
@@ -93,29 +137,61 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
}
|
||||
out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length)
|
||||
return out
|
||||
}, [q, allPaths])
|
||||
}, [q, allPaths, showHidden])
|
||||
|
||||
// flat list of content hits for keyboard nav
|
||||
const flat = useMemo(() => {
|
||||
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
|
||||
}, [content])
|
||||
}, [visibleContent])
|
||||
|
||||
const totalHits = flat.length
|
||||
useEffect(() => { setSel(0) }, [q])
|
||||
const fileCount = Math.min(files.length, 40)
|
||||
const inFileCount = Math.min(inFile.length, 200)
|
||||
useEffect(() => { setSel(0); setFileSel(0); setInFileSel(0) }, [q])
|
||||
useEffect(() => {
|
||||
const el = leftRef.current && leftRef.current.querySelector('.sr-line.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
useEffect(() => {
|
||||
const el = rightRef.current && rightRef.current.querySelector('.fres.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [fileSel])
|
||||
useEffect(() => {
|
||||
const el = inFileRef.current && inFileRef.current.querySelector('.sr-line.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [inFileSel])
|
||||
|
||||
function onKey(e: React.KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, flat.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
e.preventDefault()
|
||||
const i = Math.max(0, cols.indexOf(col))
|
||||
const ni = e.key === 'ArrowLeft' ? Math.max(0, i - 1) : Math.min(cols.length - 1, i + 1)
|
||||
setCol(cols[ni])
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (col === 'files') setFileSel((s) => Math.min(s + 1, fileCount - 1))
|
||||
else if (col === 'infile') setInFileSel((s) => Math.min(s + 1, inFileCount - 1))
|
||||
else setSel((s) => Math.min(s + 1, flat.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (col === 'files') setFileSel((s) => Math.max(s - 1, 0))
|
||||
else if (col === 'infile') setInFileSel((s) => Math.max(s - 1, 0))
|
||||
else setSel((s) => Math.max(s - 1, 0))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (col === 'infile') {
|
||||
if (activePath && inFile[inFileSel]) { onOpenAt(activePath, inFile[inFileSel].no); onClose() }
|
||||
} else if (col === 'files') {
|
||||
if (files[fileSel]) { onOpen(files[fileSel].path); onClose() }
|
||||
else if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
|
||||
} else {
|
||||
if (flat[sel]) { onOpenAt(flat[sel].path, flat[sel].no); onClose() }
|
||||
else if (files[0]) { onOpen(files[0].path); onClose() }
|
||||
}
|
||||
} else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
|
||||
@@ -133,15 +209,35 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
<div className="pi">
|
||||
{Icon.search({ style: { color: 'var(--fg-3)' } })}
|
||||
<input ref={inputRef} value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Search content and file names…" spellCheck={false} />
|
||||
<span className="mode-chip">{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||
placeholder="Search this file, the project, and file names…" spellCheck={false} />
|
||||
<span className="mode-chip">{hasInFile && <>{inFile.length} here · </>}{totalHits} hit{totalHits === 1 ? '' : 's'} · {files.length} file{files.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
<div className="search-cols">
|
||||
<div className="sc-left" ref={leftRef}>
|
||||
<div className="sc-head">Content {totalHits > 0 && <span className="sc-ct">{totalHits}</span>}</div>
|
||||
{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-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 && content.length === 0 && <div className="pempty">No content matches</div>}
|
||||
{content.map((g) => (
|
||||
{term.length >= 2 && visibleContent.length === 0 && <div className="pempty">No content matches</div>}
|
||||
{visibleContent.map((g) => (
|
||||
<Fragment key={g.path}>
|
||||
<div className="sr-file" onClick={() => onOpenAt(g.path, g.hits[0].no)}>
|
||||
<FileIcon path={g.path} />
|
||||
@@ -152,8 +248,8 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
flatIx++
|
||||
const me = flatIx
|
||||
return (
|
||||
<div key={h.no} className={'sr-line' + (me === sel ? ' sel' : '')}
|
||||
onMouseEnter={() => setSel(me)}
|
||||
<div key={h.no} className={'sr-line' + (me === sel && col === 'content' ? ' sel' : '')}
|
||||
onMouseEnter={() => { setCol('content'); setSel(me) }}
|
||||
onClick={() => { onOpenAt(g.path, h.no); onClose() }}>
|
||||
<span className="no">{h.no}</span>
|
||||
{renderLine(h.ln, h.ix, term.length)}
|
||||
@@ -163,15 +259,17 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="sc-right">
|
||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>}</div>
|
||||
<div className={'sc-right' + (col === 'files' ? ' active' : '')} ref={rightRef}>
|
||||
<div className="sc-head">Files {files.length > 0 && <span className="sc-ct">{files.length}</span>} <kbd className="col-kbd">⌘→</kbd></div>
|
||||
{!term && <div className="pempty sm">Start typing…</div>}
|
||||
{term && files.length === 0 && <div className="pempty sm">No file names match</div>}
|
||||
{files.slice(0, 40).map((r) => {
|
||||
{files.slice(0, 40).map((r, i) => {
|
||||
const name = r.path.split('/').pop() as string
|
||||
const dir = r.path.split('/').slice(0, -1).join('/')
|
||||
return (
|
||||
<div key={r.path} className="fres" onClick={() => { onOpen(r.path); onClose() }} title={r.path}>
|
||||
<div key={r.path} className={'fres' + (col === 'files' && i === fileSel ? ' sel' : '')}
|
||||
onMouseEnter={() => { setCol('files'); setFileSel(i) }}
|
||||
onClick={() => { onOpen(r.path); onClose() }} title={r.path}>
|
||||
<FileIcon path={r.path} />
|
||||
<div className="fres-txt">
|
||||
<span className="fn"><Highlight text={name} idx={r.idx} /></span>
|
||||
@@ -188,24 +286,296 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
)
|
||||
}
|
||||
|
||||
/* Recently-opened-files navigator. Looks like the search modal but is a single
|
||||
* keyboard-driven list (most-recent first). ⌘↓/⌘↑ move the selection, ↵ opens.
|
||||
* Listens in the capture phase so it owns the keyboard while open. */
|
||||
export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }: {
|
||||
history: string[]
|
||||
initialSel: number
|
||||
onOpen: OpenFile
|
||||
onClose: () => void
|
||||
changeSet: Set<string>
|
||||
}): React.ReactElement {
|
||||
const [sel, setSel] = useState(() => Math.min(Math.max(initialSel, 0), Math.max(history.length - 1, 0)))
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, history.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const p = history[selRef.current]; if (p) { onOpen(p); onClose() } }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [history])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.file({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Recent files</span>
|
||||
<span className="mode-chip">{history.length} file{history.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||
</div>
|
||||
<div className="hist-list" ref={listRef}>
|
||||
{history.length === 0 && <div className="pempty">No files opened yet</div>}
|
||||
{history.map((p, i) => {
|
||||
const name = p.split('/').pop() as string
|
||||
const dir = p.split('/').slice(0, -1).join('/')
|
||||
return (
|
||||
<div key={p} className={'hist-row' + (i === sel ? ' sel' : '')} title={p}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => { onOpen(p); onClose() }}>
|
||||
<FileIcon path={p} />
|
||||
<div className="hist-txt">
|
||||
<span className="fn">{name}</span>
|
||||
<span className="fd">{dir ? dir + '/' : ''}</span>
|
||||
</div>
|
||||
{changeSet.has(p) && <span className="tree-badge M" style={{ fontFamily: 'var(--mono)', fontSize: 10 }}>•</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Project history picker (⇧⌘O). Same keyboard model as the launcher and the
|
||||
* recent-files navigator: ⌘↑/⌘↓ (or plain arrows) move, ↵ switches the current
|
||||
* window to that project, esc closes. The currently-open project is filtered
|
||||
* out — reopening it is a no-op. */
|
||||
export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
|
||||
recents: RecentProject[]
|
||||
currentRoot: string | null
|
||||
onOpen: (path: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const list = useMemo(() => recents.filter((p) => p.path !== currentRoot), [recents, currentRoot])
|
||||
const [sel, setSel] = useState(0)
|
||||
const selRef = useRef(sel); selRef.current = sel
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, list.length - 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); const p = list[selRef.current]; if (p) { onOpen(p.path); onClose() } }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [list, onOpen, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
|
||||
if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
}, [sel])
|
||||
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.reveal({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Open recent project</span>
|
||||
<span className="mode-chip">{list.length} project{list.length === 1 ? '' : 's'} · <kbd>⌘↓</kbd> <kbd>⌘↑</kbd> <kbd>↵</kbd></span>
|
||||
</div>
|
||||
<div className="hist-list" ref={listRef}>
|
||||
{list.length === 0 && <div className="pempty">No other recent projects</div>}
|
||||
{list.map((p, i) => (
|
||||
<div key={p.path} className={'hist-row' + (i === sel ? ' sel' : '')} title={p.path}
|
||||
onMouseEnter={() => setSel(i)}
|
||||
onClick={() => { onOpen(p.path); onClose() }}>
|
||||
{Icon.reveal()}
|
||||
<div className="hist-txt">
|
||||
<span className="fn">{p.name}</span>
|
||||
<span className="fd">{p.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */
|
||||
const SHORTCUTS: { keys: string[]; label: string }[] = [
|
||||
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
|
||||
{ keys: ['⌘', '↑'], label: 'Navigate a list up' },
|
||||
{ keys: ['⌘', '↓'], label: 'Navigate a list down' },
|
||||
{ keys: ['↵'], label: 'Open the selected list item' },
|
||||
{ keys: ['⌘', '←'], label: 'Search: focus the column to the left (this file · project · names)' },
|
||||
{ keys: ['⌘', '→'], label: 'Search: focus the column to the right' },
|
||||
{ keys: ['↑', '↓'], label: 'Git/Explorer: move the row cursor (panel must be focused)' },
|
||||
{ keys: ['↵'], label: 'Git/Explorer: open the selected row' },
|
||||
{ keys: ['⌘', '→'], label: 'Git/Explorer: open the row menu · Editor: pass the selection to the agent' },
|
||||
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
|
||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
||||
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
|
||||
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
||||
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
|
||||
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
||||
{ keys: ['⌘', 'W'], label: 'Close the current file' },
|
||||
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
|
||||
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
|
||||
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
|
||||
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
||||
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
|
||||
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
||||
]
|
||||
|
||||
export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="help-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.help({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Keyboard shortcuts</span>
|
||||
<kbd>esc</kbd>
|
||||
</div>
|
||||
<div className="help-list">
|
||||
{SHORTCUTS.map((s, i) => (
|
||||
<div key={i} className="help-row">
|
||||
<span className="help-keys">{s.keys.map((k, j) => <kbd key={j}>{k}</kbd>)}</span>
|
||||
<span className="help-label">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scratch note for the project, stored as plain text in `.notes.txt`.
|
||||
*
|
||||
* The overlay only edits the text. Saving is the App's job, because the note
|
||||
* must also be written when the window loses focus with the overlay shut.
|
||||
* ⌘P (pass the note to the agent) is the App's job too — it owns the shortcut.
|
||||
*/
|
||||
export function NotesModal({ text, onChange, onClose }: {
|
||||
text: string
|
||||
onChange: (text: string) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
el.focus()
|
||||
// Caret at the end, so you carry on writing instead of overtyping.
|
||||
el.setSelectionRange(el.value.length, el.value.length)
|
||||
}, [])
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="pi">
|
||||
{Icon.note({ style: { color: 'var(--fg-3)' } })}
|
||||
<span className="hist-title">Note</span>
|
||||
<span className="notes-file">.notes.txt</span>
|
||||
<span className="notes-hint">{Icon.spark()} To agent <kbd>⌘P</kbd></span>
|
||||
<kbd>esc</kbd>
|
||||
</div>
|
||||
<textarea ref={ref} className="notes-input" spellCheck={false}
|
||||
placeholder="Anything you want to keep next to this project…"
|
||||
value={text} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
|
||||
* it owns the keyboard while open. */
|
||||
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
|
||||
title: string
|
||||
body?: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onConfirm(); onClose() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => window.removeEventListener('keydown', onKey, true)
|
||||
}, [])
|
||||
return (
|
||||
<div className="scrim" onMouseDown={onClose}>
|
||||
<div className="confirm-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cf-title">{title}</div>
|
||||
{body && <div className="cf-body">{body}</div>}
|
||||
<div className="cf-actions">
|
||||
<button className="cf-btn" onClick={onClose}>Cancel <kbd>esc</kbd></button>
|
||||
<button className={'cf-btn cf-yes' + (danger ? ' danger' : '')} onClick={() => { onConfirm(); onClose() }} autoFocus>
|
||||
{confirmLabel ?? 'Confirm'} <kbd>↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Index of the first selectable (non-separator) item, for keyboard highlight.
|
||||
const items = menu?.items ?? []
|
||||
const firstSel = items.findIndex((it) => !it.sep)
|
||||
const [hi, setHi] = useState(firstSel)
|
||||
const hiRef = useRef(hi); hiRef.current = hi
|
||||
// Reset the highlight to the first selectable item whenever the menu reopens
|
||||
// (right-click can swap the target without unmounting this component).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { setHi(items.findIndex((it) => !it.sep)) }, [menu])
|
||||
// Step the highlight to the next/previous selectable item, skipping separators.
|
||||
function step(dir: 1 | -1): void {
|
||||
setHi((cur) => {
|
||||
let i = cur
|
||||
for (let n = 0; n < items.length; n++) {
|
||||
i = (i + dir + items.length) % items.length
|
||||
if (!items[i].sep) return i
|
||||
}
|
||||
return cur
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent): void => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() }
|
||||
const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') onClose() }
|
||||
const k = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); step(1) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); e.stopPropagation(); step(-1) }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const it = items[hiRef.current]
|
||||
if (it && !it.sep) { it.onClick?.(); onClose() }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', h)
|
||||
document.addEventListener('keydown', k)
|
||||
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k) }
|
||||
}, [])
|
||||
document.addEventListener('keydown', k, true)
|
||||
return () => { document.removeEventListener('mousedown', h); document.removeEventListener('keydown', k, true) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length])
|
||||
if (!menu) return null
|
||||
const x = Math.min(menu.x, window.innerWidth - 270)
|
||||
const y = Math.min(menu.y, window.innerHeight - (menu.items.length * 34 + 60))
|
||||
return (
|
||||
<div className="ctx" 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.items.map((it, i) => it.sep ? <div key={i} className="ctx-sep" /> : (
|
||||
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '')}
|
||||
onClick={() => { it.onClick && it.onClick(); onClose() }}>
|
||||
<div key={i} className={'ctx-item' + (it.primary ? ' primary' : '') + (i === hi ? ' hi' : '')}
|
||||
onMouseEnter={() => setHi(i)}
|
||||
onClick={() => { it.onClick?.(); onClose() }}>
|
||||
<span className="ic">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
||||
@@ -229,17 +599,18 @@ export function Toasts({ toasts }: { toasts: Toast[] }): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
||||
export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
|
||||
x: number
|
||||
y: number
|
||||
refStr: string
|
||||
onConfirm: (text: string) => void
|
||||
code?: string
|
||||
onConfirm: (payload: string) => void
|
||||
onCancel: () => void
|
||||
}): React.ReactElement {
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const boxRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => { inputRef.current && inputRef.current.focus() }, [])
|
||||
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() } }
|
||||
@@ -249,7 +620,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
||||
}, [])
|
||||
const left = Math.min(x, window.innerWidth - 360)
|
||||
const top = Math.min(y + 6, window.innerHeight - 150)
|
||||
const preview = (text.trim() ? text.trim() + ' ' : '') + refStr
|
||||
const payload = buildPass(text, refStr, code)
|
||||
return (
|
||||
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
|
||||
<div className="pass-head">{Icon.spark()}<span>Pass on to Agent</span><span className="pass-esc">esc</span></div>
|
||||
@@ -257,11 +628,51 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
||||
placeholder="Add a note (optional)…"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
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() }
|
||||
}} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
37
src/renderer/src/persist.ts
Normal file
37
src/renderer/src/persist.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Tiny localStorage-backed number persistence for layout (splitter positions
|
||||
* persist across launches). Defensive: never throws if storage is unavailable. */
|
||||
export function loadNum(key: string, fallback: number): number {
|
||||
try {
|
||||
const v = localStorage.getItem(key)
|
||||
if (v == null) return fallback
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNum(key: string, value: number): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJson<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const v = localStorage.getItem(key)
|
||||
return v == null ? fallback : (JSON.parse(v) as T)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(key: string, value: unknown): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,42 @@
|
||||
* already consumed from the mock. When window.helder is absent (e.g. a plain
|
||||
* browser preview) it falls back to the mock so the UI still renders. */
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Change, Diff, FileNode, HelderConfig } from './types'
|
||||
import { DEFAULT_CONFIG } from './types'
|
||||
import type { Change, Diff, FileNode, GitStatus, HelderConfig } from './types'
|
||||
import { DEFAULT_CONFIG, rowId } from './types'
|
||||
import { makeDiff } from './diff'
|
||||
import { rlog } from './log'
|
||||
import { PROJECT as MOCK } from './data'
|
||||
|
||||
export interface RecentProject { path: string; name: string }
|
||||
|
||||
export interface ProjectData {
|
||||
name: string
|
||||
root: string | null
|
||||
branch: string
|
||||
tree: FileNode | null
|
||||
files: Record<string, string>
|
||||
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
|
||||
changes: Change[]
|
||||
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
|
||||
diffs: Record<string, Diff>
|
||||
/** Per row id: that row's own pair. Drives Diff and Split. */
|
||||
rowDiffs: Record<string, Diff>
|
||||
/** Paths that have a staged row. */
|
||||
staged: Set<string>
|
||||
config: HelderConfig
|
||||
isRepo: boolean
|
||||
ready: boolean
|
||||
// Prefetched at startup (parallel to the project load) so the launcher paints
|
||||
// its list with no extra round-trip when there's no project open.
|
||||
recents: RecentProject[]
|
||||
}
|
||||
|
||||
/** Immutable copy of the tree with the children of the node at `path` replaced.
|
||||
* Root is path '' . Returns the tree unchanged when the path isn't found. */
|
||||
function replaceChildren(tree: FileNode, path: string, children: FileNode[]): FileNode {
|
||||
if (tree.path === path) return { ...tree, children }
|
||||
if (!tree.children) return tree
|
||||
return { ...tree, children: tree.children.map((c) => replaceChildren(c, path, children)) }
|
||||
}
|
||||
|
||||
/** Inject the project's theme.css over the built-in dark theme. */
|
||||
@@ -35,29 +54,60 @@ function applyTheme(css: string): void {
|
||||
|
||||
export interface ProjectActions {
|
||||
openFolder: () => void
|
||||
openProjectPath: (path: string) => void
|
||||
refresh: () => void
|
||||
/** Re-read a single folder's children from disk and splice them into the tree.
|
||||
* Called on every folder expand/collapse so the row reflects on-disk truth
|
||||
* (files added/removed by the agent or an external tool) without a full walk. */
|
||||
refreshDir: (path: string) => void
|
||||
refreshGit: () => void
|
||||
stage: (path: string) => void
|
||||
unstage: (path: string) => void
|
||||
stageAll: () => void
|
||||
unstageAll: () => void
|
||||
commit: (message: string) => Promise<number>
|
||||
push: () => Promise<{ ok: boolean; message: string }>
|
||||
discard: (path: string) => void
|
||||
ensureFile: (path: string) => void
|
||||
/** Force re-read a file from disk into the content index, returning the fresh
|
||||
* text. Unlike ensureFile (which only fills a gap), this always overwrites the
|
||||
* cached copy — so the editable view reflects on-disk truth after the agent or
|
||||
* an external tool rewrites the file. */
|
||||
reloadFile: (path: string) => Promise<string>
|
||||
}
|
||||
|
||||
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
|
||||
|
||||
/** Preview mode has no index, so each mock file is one row. The staged set says
|
||||
* which group it lands in. */
|
||||
function mockChanges(staged: Set<string>): Change[] {
|
||||
return MOCK.changes.map((c) => {
|
||||
const s = staged.has(c.path)
|
||||
return { ...c, staged: s, id: rowId(c.path, s) }
|
||||
})
|
||||
}
|
||||
|
||||
function mockRowDiffs(changes: Change[]): Record<string, Diff> {
|
||||
const out: Record<string, Diff> = {}
|
||||
for (const c of changes) out[c.id] = MOCK.diffs[c.path]
|
||||
return out
|
||||
}
|
||||
|
||||
function mockData(): ProjectData {
|
||||
const staged = new Set(MOCK_STAGED)
|
||||
const changes = mockChanges(staged)
|
||||
return {
|
||||
name: MOCK.name, root: null, branch: MOCK.branch,
|
||||
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs,
|
||||
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true,
|
||||
// non-null root so browser-preview shows the workbench, not the launcher
|
||||
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
|
||||
tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
|
||||
rowDiffs: mockRowDiffs(changes),
|
||||
staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
|
||||
}
|
||||
}
|
||||
|
||||
const emptyData: ProjectData = {
|
||||
name: 'Loading…', root: null, branch: '—', tree: null, files: {},
|
||||
changes: [], diffs: {}, 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 }>({
|
||||
@@ -65,6 +115,37 @@ const Ctx = createContext<{ data: ProjectData; actions: 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 {
|
||||
return useContext(Ctx).data
|
||||
}
|
||||
@@ -77,33 +158,69 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
const [data, setData] = useState<ProjectData>(emptyData)
|
||||
const dataRef = useRef(data)
|
||||
dataRef.current = data
|
||||
const loadSeq = useRef(0)
|
||||
// Git has its OWN counter. It used to share loadSeq, which quietly broke ⌘R:
|
||||
// the git poll (git.refreshInterval, 10s) and the git-column focus refresh
|
||||
// both bump the counter, so any full reload still in flight — the tree walk is
|
||||
// the slow part — saw seq !== loadSeq and bailed out completely. The tree then
|
||||
// never updated while git kept looking fine. Two counters: a git-only read can
|
||||
// no longer cancel a tree read, and each still settles on its newest result.
|
||||
const gitSeq = useRef(0)
|
||||
|
||||
async function loadReal(): Promise<void> {
|
||||
if (!bridge) return
|
||||
const [cur, tree, files, git, config, theme] = await Promise.all([
|
||||
bridge.project.current(),
|
||||
// A branch switch fires several watcher pings in quick succession, so
|
||||
// multiple loadReal() calls overlap. Tag each one and only let the newest
|
||||
// apply its result — otherwise a slow/transient mid-checkout read can
|
||||
// resolve last and clobber the correct settled state.
|
||||
const seq = ++loadSeq.current
|
||||
const gseq = ++gitSeq.current
|
||||
const cur = await bridge.project.current()
|
||||
if (seq !== loadSeq.current) return
|
||||
// Bare launch (Spotlight / no project): flip ready immediately so the
|
||||
// launcher paints, skipping the tree/files/git/theme reads it doesn't need.
|
||||
if (!cur.root) {
|
||||
setData((d) => ({ ...emptyData, name: cur.name, root: null, ready: true, recents: d.recents }))
|
||||
return
|
||||
}
|
||||
const [tree, git, config, theme] = await Promise.all([
|
||||
bridge.fs.tree(),
|
||||
bridge.fs.files(),
|
||||
bridge.git.load(),
|
||||
bridge.config.get(),
|
||||
bridge.config.theme(),
|
||||
])
|
||||
if (seq !== loadSeq.current) return
|
||||
applyTheme(theme)
|
||||
const changes: Change[] = []
|
||||
const diffs: Record<string, Diff> = {}
|
||||
const staged = new Set<string>()
|
||||
if (git) {
|
||||
for (const c of git.changes) {
|
||||
const d = makeDiff(c.status, c.original, c.updated)
|
||||
diffs[c.path] = d
|
||||
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 git slice is only applied if no newer git-only read has started since;
|
||||
// otherwise keep the fresher git state and update everything else.
|
||||
const gitFresh = gseq === gitSeq.current
|
||||
setData((d) => ({
|
||||
name: cur.name, root: cur.root,
|
||||
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
|
||||
...(gitFresh ? deriveGit(git) : { branch: d.branch, changes: d.changes, diffs: d.diffs, rowDiffs: d.rowDiffs, staged: d.staged, isRepo: d.isRepo }),
|
||||
}))
|
||||
// The whole-repo content index is only a fallback (real viewing/search go
|
||||
// through fs.read + ripgrep), and reading every file serially costs seconds.
|
||||
// Build it in the background and patch it in — never block the workbench on
|
||||
// it. Lazily-loaded files (ensureFile) win over the bulk read.
|
||||
bridge.fs.files().then((files) => {
|
||||
if (seq !== loadSeq.current) return
|
||||
setData((d) => ({ ...d, files: { ...(files || {}), ...d.files } }))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
setData({
|
||||
name: cur.name, root: cur.root, branch: git ? git.branch : '—',
|
||||
tree, files: files || {}, changes, diffs, staged, config, isRepo: !!git, ready: true,
|
||||
})
|
||||
|
||||
// Fast path for the three git mutations (stage / unstage / commit) + discard:
|
||||
// re-read ONLY git status and patch the git fields, skipping the full
|
||||
// loadReal() that also re-walks the file tree + content index. This is the
|
||||
// direct, immediate refresh those actions trigger — the .git watcher stays a
|
||||
// backstop for git changes made by external tools. Uses gitSeq only, so it
|
||||
// never cancels an in-flight full reload (see the gitSeq note above).
|
||||
async function loadGit(): Promise<void> {
|
||||
if (!bridge) return
|
||||
const seq = ++gitSeq.current
|
||||
const git = await bridge.git.load()
|
||||
if (seq !== gitSeq.current) return
|
||||
setData((d) => ({ ...d, ...deriveGit(git) }))
|
||||
}
|
||||
|
||||
async function loadConfigTheme(): Promise<void> {
|
||||
@@ -115,6 +232,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
|
||||
useEffect(() => {
|
||||
if (!bridge) { setData(mockData()); return }
|
||||
// Fetch recents in parallel with the project load (not after it) so the list
|
||||
// is already in state by the time the launcher mounts.
|
||||
bridge.project.recent()
|
||||
.then((list) => setData((d) => ({ ...d, recents: list })))
|
||||
.catch(() => {})
|
||||
loadReal().catch(() => setData((d) => ({ ...d, ready: true })))
|
||||
const offProject = bridge.onProjectChanged(() => { loadReal().catch(() => {}) })
|
||||
const offConfig = bridge.onConfigChanged(() => { loadConfigTheme().catch(() => {}) })
|
||||
@@ -126,38 +248,55 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
if (!bridge) {
|
||||
// ---- mock-mode actions (preview only) ----
|
||||
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 {
|
||||
openFolder: () => {},
|
||||
openProjectPath: () => {},
|
||||
refresh: () => setData(mockData()),
|
||||
refreshDir: () => {},
|
||||
refreshGit: () => {},
|
||||
stage: (p) => setStaged((s) => (s.add(p), s)),
|
||||
unstage: (p) => setStaged((s) => (s.delete(p), s)),
|
||||
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })),
|
||||
unstageAll: () => setData((d) => ({ ...d, staged: new Set() })),
|
||||
stageAll: () => setStaged(() => new Set(dataRef.current.changes.map((c) => c.path))),
|
||||
unstageAll: () => setStaged(() => new Set()),
|
||||
commit: async (_msg) => {
|
||||
const cur = dataRef.current
|
||||
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length
|
||||
setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() }))
|
||||
const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
|
||||
setData((d) => ({ ...d, changes: d.changes.filter((c) => !c.staged), staged: new Set() }))
|
||||
return n
|
||||
},
|
||||
push: async () => ({ ok: true, message: 'Pushed (preview)' }),
|
||||
discard: (p) => setData((d) => ({
|
||||
...d,
|
||||
changes: d.changes.filter((c) => c.path !== p),
|
||||
staged: (() => { const s = new Set(d.staged); s.delete(p); return s })(),
|
||||
})),
|
||||
ensureFile: () => {},
|
||||
reloadFile: async (p) => dataRef.current.files[p] ?? '',
|
||||
}
|
||||
}
|
||||
// ---- real git-backed actions ----
|
||||
const after = (op: Promise<unknown>): void => { op.then(() => loadReal()).catch(() => {}) }
|
||||
const after = (op: Promise<unknown>): void => { op.then(() => loadGit()).catch(() => {}) }
|
||||
return {
|
||||
openFolder: () => { bridge.project.open().then(() => loadReal()).catch(() => {}) },
|
||||
openProjectPath: (path) => { bridge.project.openPath(path).then(() => loadReal()).catch(() => {}) },
|
||||
refresh: () => { loadReal().catch(() => {}) },
|
||||
refreshDir: (path) => {
|
||||
bridge.fs.readDir(path).then((children) => {
|
||||
setData((d) => (d.tree ? { ...d, tree: replaceChildren(d.tree, path, children) } : d))
|
||||
}).catch(() => {})
|
||||
},
|
||||
refreshGit: () => { loadGit().catch(() => {}) },
|
||||
stage: (p) => after(bridge.git.stage([p])),
|
||||
unstage: (p) => after(bridge.git.unstage([p])),
|
||||
stageAll: () => {
|
||||
const cur = dataRef.current
|
||||
const unstaged = cur.changes.filter((c) => !cur.staged.has(c.path)).map((c) => c.path)
|
||||
// Every path with an unstaged row — including files that already have a
|
||||
// staged row and were edited again since.
|
||||
const unstaged = [...new Set(dataRef.current.changes.filter((c) => !c.staged).map((c) => c.path))]
|
||||
if (unstaged.length) after(bridge.git.stage(unstaged))
|
||||
},
|
||||
unstageAll: () => {
|
||||
@@ -166,17 +305,34 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
},
|
||||
commit: async (msg) => {
|
||||
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 loadReal()
|
||||
await loadGit()
|
||||
return n
|
||||
},
|
||||
push: async () => {
|
||||
const r = await bridge.git.push()
|
||||
await loadGit()
|
||||
return r
|
||||
},
|
||||
discard: (p) => after(bridge.git.discard([p])),
|
||||
ensureFile: (path) => {
|
||||
if (dataRef.current.files[path] != null) return
|
||||
bridge.fs.read(path).then((txt) => {
|
||||
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
|
||||
}).catch(() => {})
|
||||
}).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
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */
|
||||
:root {
|
||||
--bg-0:#16171a; /* editor surface (deepest) */
|
||||
--bg-1:#1a1c1f; /* terminals */
|
||||
--bg-2:#1f2226; /* sidebars */
|
||||
--bg-3:#23262b; /* headers / tabs strip */
|
||||
--hover:#2a2e34;
|
||||
--active:#313742;
|
||||
--sel:#2b323d;
|
||||
--border:#2a2d33;
|
||||
--border-2:#34383f;
|
||||
--fg-0:#e6e8ea;
|
||||
--fg-1:#b4bac2;
|
||||
--fg-2:#838a94;
|
||||
--fg-3:#5d636c;
|
||||
--accent:#4d8dff;
|
||||
--accent-soft:rgba(77,141,255,0.16);
|
||||
--accent-line:rgba(77,141,255,0.55);
|
||||
--bg-0:#2b2e34; /* editor surface (deepest) */
|
||||
--bg-1:#30343b; /* terminals */
|
||||
--bg-2:#373c44; /* sidebars */
|
||||
--bg-3:#40454e; /* headers / tabs strip */
|
||||
--hover:#4a505a;
|
||||
--active:#535a66;
|
||||
--sel:#49525f;
|
||||
--border:#474c55;
|
||||
--border-2:#535963;
|
||||
--fg-0:#fbfcfd;
|
||||
--fg-1:#dde1e7;
|
||||
--fg-2:#b0b6bf;
|
||||
--fg-3:#8f96a0;
|
||||
--accent:#f19f3f;
|
||||
|
||||
|
||||
|
||||
--accent-soft:rgba(241,159,63,0.16);
|
||||
--accent-line:rgba(241,159,63,0.55);
|
||||
--add:#5cbd6b;
|
||||
--del:#e0696a;
|
||||
--mod:#d8a85c;
|
||||
@@ -50,7 +53,13 @@ body {
|
||||
overflow:hidden; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
#root { height:100vh; }
|
||||
::selection { background:rgba(77,141,255,0.32); }
|
||||
::selection { background:rgba(241,159,63,0.30); }
|
||||
|
||||
/* One key chip, used by every shortcut hint in the app — title bar, modal
|
||||
headers, empty editor, context hints. Components may only add layout
|
||||
(flex, min-width, alignment) or a colour that their own surface demands. */
|
||||
kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3);
|
||||
background:transparent; border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
|
||||
/* scrollbars */
|
||||
::-webkit-scrollbar { width:11px; height:11px; }
|
||||
@@ -62,31 +71,81 @@ body {
|
||||
.app { display:flex; flex-direction:column; height:100vh; }
|
||||
|
||||
.titlebar {
|
||||
height:36px; flex:0 0 36px; display:flex; align-items:center;
|
||||
height:36px; flex:0 0 36px; display:flex; align-items:center; position:relative;
|
||||
background:var(--bg-3); border-bottom:1px solid var(--border);
|
||||
padding:0 12px; gap:14px; user-select:none;
|
||||
}
|
||||
/* branch - repository, centered in the bar */
|
||||
.tb-repo {
|
||||
position:absolute; left:50%; transform:translateX(-50%);
|
||||
display:flex; align-items:center; gap:6px; pointer-events:none;
|
||||
font-family:var(--mono); font-size:11.5px; color:var(--fg-2);
|
||||
max-width:46%; white-space:nowrap; overflow:hidden;
|
||||
}
|
||||
.tb-repo svg { color:var(--accent); flex:0 0 auto; }
|
||||
.tb-repo-branch { color:var(--fg-3); }
|
||||
.tb-repo-sep { color:var(--fg-3); }
|
||||
.tb-repo-name { color:var(--fg-0); font-weight:600; overflow:hidden; text-overflow:ellipsis; }
|
||||
/* focus flash — branch - repository, briefly centered; same inline style as the header label, scaled up */
|
||||
.focus-flash {
|
||||
position:fixed; inset:0; z-index:200; pointer-events:none;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
animation:ff-fade 2s ease forwards;
|
||||
}
|
||||
.ff-card {
|
||||
display:flex; align-items:center; gap:9px;
|
||||
font-family:var(--mono); font-size:24px; line-height:1;
|
||||
padding:20px 34px; border-radius:14px;
|
||||
background:rgba(22,23,26,0.82); border:1px solid var(--border-2);
|
||||
backdrop-filter:blur(8px); box-shadow:0 18px 60px rgba(0,0,0,0.5);
|
||||
}
|
||||
.ff-card svg { color:var(--accent); flex:0 0 auto; }
|
||||
@keyframes ff-fade { 0%{opacity:0;} 12%{opacity:1;} 72%{opacity:1;} 100%{opacity:0;} }
|
||||
@media (prefers-reduced-motion:reduce) { .focus-flash { animation:ff-fade-rm 2s steps(1) forwards; } @keyframes ff-fade-rm { 0%{opacity:1;} 99%{opacity:1;} 100%{opacity:0;} } }
|
||||
.traffic { display:flex; gap:8px; }
|
||||
.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
|
||||
.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
|
||||
.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
|
||||
.tb-title b { color:var(--fg-0); font-weight:600; }
|
||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
|
||||
.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); display:flex; align-items:center; gap:2px; }
|
||||
.tb-crumb .seg{color:var(--fg-2);}
|
||||
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
|
||||
.tb-spacer { flex:1; }
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
.tb-toggle.on, .tb-toggle.on:hover { color:var(--accent); }
|
||||
.tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
|
||||
.tb-toggle.on:hover { background:var(--accent-soft); }
|
||||
|
||||
.workbench { flex:1; display:flex; min-height:0; }
|
||||
|
||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
|
||||
.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; }
|
||||
.col.right-col { background:var(--bg-1); }
|
||||
/* --col-bg holds each column's resting background so the ⌘R flash below can
|
||||
animate back to it, whichever state the column is in. */
|
||||
.col { display:flex; flex-direction:column; height:100%; min-width:0; --col-bg:var(--bg-2); background:var(--col-bg); }
|
||||
/* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */
|
||||
.col.editor-col { flex:1; min-width:240px; }
|
||||
.col.right-col { --col-bg:var(--bg-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::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
|
||||
@@ -109,8 +168,10 @@ body {
|
||||
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
|
||||
.commit-input:focus { border-color:var(--accent-line); }
|
||||
.commit-input::placeholder { color:var(--fg-3); }
|
||||
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
|
||||
.commit-btn:hover:not(:disabled) { background:#5d97ff; }
|
||||
.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:hover:not(:disabled) { background:#f6b35f; }
|
||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||
.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
|
||||
.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
|
||||
@@ -126,20 +187,22 @@ body {
|
||||
.git-row {
|
||||
display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
|
||||
}
|
||||
.git-row:hover { background:var(--hover); }
|
||||
.git-row:hover, .git-row.ctx { background:var(--hover); }
|
||||
.git-row.ctx .git-act { visibility:visible; }
|
||||
.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.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.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
|
||||
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.git-row.active .git-name { color:var(--fg-0); }
|
||||
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
|
||||
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
|
||||
.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
||||
.git-row:hover .git-act { display:flex; }
|
||||
.git-act { flex:0 0 auto; display:flex; visibility:hidden; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
|
||||
.git-act.push { margin-left:auto; } /* right-align when the dir column is hidden */
|
||||
.git-row:hover .git-act { visibility:visible; }
|
||||
.git-act:hover { background:var(--active); color:var(--fg-0); }
|
||||
.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
|
||||
.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
|
||||
.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
|
||||
.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
|
||||
.branch-chip b { font-weight:600; color:var(--fg-0); }
|
||||
@@ -147,9 +210,10 @@ body {
|
||||
/* ============ file tree ============ */
|
||||
.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
|
||||
.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
|
||||
.tree-row:hover { background:var(--hover); }
|
||||
.tree-row:hover, .tree-row.ctx { background:var(--hover); }
|
||||
.tree-row.active { background:var(--sel); }
|
||||
.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
|
||||
.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; }
|
||||
.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
|
||||
.tree-row.active .tree-label { color:var(--fg-0); }
|
||||
@@ -164,29 +228,19 @@ body {
|
||||
.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
||||
|
||||
/* ============ editor ============ */
|
||||
.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
|
||||
.tabs::-webkit-scrollbar { height:0; }
|
||||
.tab {
|
||||
display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
|
||||
border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
|
||||
background:var(--bg-3); position:relative; max-width:230px;
|
||||
}
|
||||
.tab:hover { background:#272b31; }
|
||||
.tab.active { background:var(--bg-0); color:var(--fg-0); }
|
||||
.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
|
||||
.tab .tname { overflow:hidden; text-overflow:ellipsis; }
|
||||
.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
|
||||
.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
|
||||
.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
|
||||
.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
|
||||
.tab.dirtyclose .tclose { display:none; }
|
||||
.tab.dirtyclose:hover .tclose { display:flex; }
|
||||
.tab.dirtyclose:hover .tdot { display:none; }
|
||||
.tab.dirtyclose .tdot { display:block; }
|
||||
.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
|
||||
|
||||
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
|
||||
.editor { flex:1; overflow:auto; font-family:var(--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.cursor { background:rgba(255,255,255,0.035); }
|
||||
.ln-row.add { background:var(--add-bg); }
|
||||
@@ -198,11 +252,13 @@ body {
|
||||
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
|
||||
.ln-row.add .ln-sign { color:var(--add); }
|
||||
.ln-row.del .ln-sign { color:var(--del); }
|
||||
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
|
||||
/* 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; }
|
||||
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
|
||||
.empty-ed .big { font-size:13px; }
|
||||
.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
|
||||
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
|
||||
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
|
||||
|
||||
@@ -219,6 +275,51 @@ body {
|
||||
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
|
||||
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
|
||||
.diff-bar .seg .split-btn svg { opacity:.85; }
|
||||
.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) */
|
||||
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
|
||||
@@ -232,32 +333,32 @@ body {
|
||||
.split-head .git-stat { font-size:11px; }
|
||||
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
|
||||
.split-exit:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
|
||||
.split-body { flex:1; display:flex; min-height:0; }
|
||||
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
|
||||
.split-pane.left { border-right:1px solid var(--border-2); }
|
||||
.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
|
||||
.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
|
||||
/* syntax token colors */
|
||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
|
||||
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
|
||||
.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
|
||||
.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
|
||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
|
||||
.ln-code .token.tag{color:var(--t-tag);}
|
||||
.ln-code .token.attr-name{color:var(--t-attr);}
|
||||
.ln-code .token.punctuation{color:var(--t-punc);}
|
||||
.ln-code .token.operator{color:var(--t-punc);}
|
||||
.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
|
||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
|
||||
.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
|
||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
|
||||
.ln-code .token.parameter{color:var(--fg-0);}
|
||||
.ln-code .token.namespace{color:var(--fg-2);}
|
||||
.ln-code .token.selector{color:var(--t-tag);}
|
||||
.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
|
||||
.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
|
||||
/* syntax token colors — applied to both the read-only line views (.ln-code)
|
||||
* and the editable buffer's highlight layer (.ce-pre) */
|
||||
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}
|
||||
.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex,.ce-pre .token.string,.ce-pre .token.attr-value,.ce-pre .token.char,.ce-pre .token.regex{color:var(--t-str);}
|
||||
.ln-code .token.number,.ln-code .token.unit,.ce-pre .token.number,.ce-pre .token.unit{color:var(--t-num);}
|
||||
.ln-code .token.function,.ln-code .token.method,.ce-pre .token.function,.ce-pre .token.method{color:var(--t-fn);}
|
||||
.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata,.ce-pre .token.comment,.ce-pre .token.prolog,.ce-pre .token.doctype,.ce-pre .token.cdata{color:var(--t-com);font-style:italic;}
|
||||
.ln-code .token.tag,.ce-pre .token.tag{color:var(--t-tag);}
|
||||
.ln-code .token.attr-name,.ce-pre .token.attr-name{color:var(--t-attr);}
|
||||
.ln-code .token.punctuation,.ce-pre .token.punctuation{color:var(--t-punc);}
|
||||
.ln-code .token.operator,.ce-pre .token.operator{color:var(--t-punc);}
|
||||
.ln-code .token.variable,.ln-code .token.symbol,.ce-pre .token.variable,.ce-pre .token.symbol{color:var(--t-var);}
|
||||
.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin,.ce-pre .token.constant,.ce-pre .token.boolean,.ce-pre .token.builtin{color:var(--t-const);}
|
||||
.ln-code .token.property,.ln-code .token.property-access,.ce-pre .token.property,.ce-pre .token.property-access{color:var(--t-prop);}
|
||||
.ln-code .token.class-name,.ln-code .token.maybe-class-name,.ce-pre .token.class-name,.ce-pre .token.maybe-class-name{color:var(--t-attr);}
|
||||
.ln-code .token.parameter,.ce-pre .token.parameter{color:var(--fg-0);}
|
||||
.ln-code .token.namespace,.ce-pre .token.namespace{color:var(--fg-2);}
|
||||
.ln-code .token.selector,.ce-pre .token.selector{color:var(--t-tag);}
|
||||
.ln-code .token.entity,.ln-code .token.url,.ce-pre .token.entity,.ce-pre .token.url{color:var(--t-prop);}
|
||||
.ln-code .token.deleted,.ce-pre .token.deleted{color:var(--del);} .ln-code .token.inserted,.ce-pre .token.inserted{color:var(--add);}
|
||||
|
||||
/* ============ terminals (right column) ============ */
|
||||
.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
|
||||
@@ -289,9 +390,11 @@ body {
|
||||
/* ============ overlays ============ */
|
||||
.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
|
||||
.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
|
||||
.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
|
||||
.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
|
||||
.palette .pi, .search-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.palette .pi input, .search-modal .pi input { flex:1; min-width:0; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
|
||||
.palette .pi input::placeholder, .search-modal .pi input::placeholder { color:var(--fg-3); }
|
||||
.palette .pi svg, .search-modal .pi svg { flex:0 0 auto; }
|
||||
.palette .pi .mode-chip, .search-modal .pi .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
|
||||
.palette .results { max-height:380px; overflow:auto; padding:6px; }
|
||||
.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
|
||||
.pres.sel { background:var(--accent-soft); }
|
||||
@@ -301,12 +404,16 @@ body {
|
||||
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
|
||||
|
||||
/* 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; }
|
||||
.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
|
||||
.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
|
||||
.sc-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-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-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; }
|
||||
.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
|
||||
@@ -317,8 +424,23 @@ body {
|
||||
.fres-txt .fn b { color:var(--accent); font-weight:700; }
|
||||
.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* recent-files navigator — single-column, keyboard-driven, search-modal styling */
|
||||
.history-modal { width:560px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
||||
.history-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.history-modal .pi svg { flex:0 0 auto; }
|
||||
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
||||
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
|
||||
.hist-list { max-height:460px; overflow:auto; padding:5px 0; }
|
||||
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
|
||||
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
|
||||
.hist-row:hover { background:var(--hover); }
|
||||
.hist-row.sel:hover { background:rgba(241,159,63,0.20); }
|
||||
.hist-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
|
||||
.hist-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
/* content search */
|
||||
.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:hover { color:var(--fg-0); }
|
||||
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
|
||||
@@ -341,8 +463,8 @@ body {
|
||||
.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
|
||||
.pass-preview .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.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 kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||
|
||||
/* terminal multi-line input */
|
||||
.term-input { align-items:flex-start; }
|
||||
@@ -350,9 +472,13 @@ body {
|
||||
.term-ta::placeholder { color:var(--fg-3); }
|
||||
|
||||
/* context menu */
|
||||
.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
|
||||
/* 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: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.primary { color:var(--fg-0); }
|
||||
.ctx-item.primary .ic { color:var(--accent); }
|
||||
@@ -368,14 +494,6 @@ body {
|
||||
.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
|
||||
|
||||
/* ============ status bar ============ */
|
||||
.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
|
||||
.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
|
||||
.sb:hover { background:var(--hover); }
|
||||
.sb.accent { background:var(--accent); color:#0c1320; }
|
||||
.sb.accent:hover { background:#5d97ff; }
|
||||
.sb.spacer { flex:1; }
|
||||
.sb .a{color:var(--add);} .sb .d{color:var(--del);}
|
||||
.sb b { font-weight:600; color:var(--fg-1); }
|
||||
|
||||
/* editable buffer — transparent textarea over a highlighted <pre>, synced gutter */
|
||||
.code-edit { flex:1; min-height:0; display:flex; overflow:hidden; }
|
||||
@@ -383,7 +501,9 @@ body {
|
||||
.ce-gutter { padding-top:6px; will-change:transform; }
|
||||
.ce-gutter 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-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 {
|
||||
margin:0; padding:6px 16px 40px 6px; border:0;
|
||||
font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
|
||||
@@ -394,7 +514,7 @@ body {
|
||||
position:absolute; inset:0; resize:none; outline:none; overflow:hidden;
|
||||
background:transparent; color:transparent; caret-color:var(--accent);
|
||||
}
|
||||
.ce-ta::selection { background:rgba(77,141,255,0.32); }
|
||||
.ce-ta::selection { background:rgba(241,159,63,0.30); }
|
||||
|
||||
/* xterm.js host (real terminals) */
|
||||
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
|
||||
@@ -406,6 +526,8 @@ body {
|
||||
decorative dots are hidden and the bar is made draggable. Interactive controls
|
||||
opt back out of the drag region. */
|
||||
.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 button,
|
||||
.titlebar input,
|
||||
@@ -415,3 +537,74 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
|
||||
/* ============ project launcher (no project open) ============ */
|
||||
.launcher { position:fixed; inset:0; background:var(--bg-0); display:flex; align-items:center; justify-content:center; }
|
||||
.launcher-drag { position:absolute; top:0; left:0; right:0; height:40px; -webkit-app-region:drag; }
|
||||
.launcher-card { width:560px; max-width:92vw; max-height:80vh; display:flex; flex-direction:column; background:var(--bg-2); border:1px solid var(--border-2); border-radius:14px; box-shadow:0 28px 80px rgba(0,0,0,.55); overflow:hidden; -webkit-app-region:no-drag; }
|
||||
.lp-head { display:flex; align-items:center; gap:12px; padding:18px 20px; border-bottom:1px solid var(--border); }
|
||||
.lp-title { display:flex; flex-direction:column; line-height:1.3; }
|
||||
.lp-title b { font-size:16px; color:var(--fg-0); font-weight:600; }
|
||||
.lp-title span { font-size:12px; color:var(--fg-3); }
|
||||
.lp-list { overflow:auto; padding:6px; flex:1; min-height:0; }
|
||||
.lp-sec { padding:10px 10px 4px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); }
|
||||
.lp-row { display:flex; align-items:center; gap:11px; padding:9px 10px; border-radius:8px; cursor:pointer; }
|
||||
.lp-row:hover { background:var(--hover); }
|
||||
.lp-row.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }
|
||||
.lp-ic { flex:0 0 auto; width:22px; height:22px; display:flex; align-items:center; justify-content:center; color:var(--fg-2); }
|
||||
.lp-new .lp-ic { color:var(--accent); }
|
||||
.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; }
|
||||
.lp-name { font-size:13px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; }
|
||||
.lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); }
|
||||
|
||||
/* ============ keyboard-shortcuts (help) modal ============ */
|
||||
/* Project note (.notes.txt). Capped at 1000px so the text stays readable on a
|
||||
wide screen; the height fills nearly the whole window, with a floor for small
|
||||
ones, because a note is usually long. */
|
||||
.notes-modal { width:1000px; max-width:92vw; height:calc(100vh - 116px); min-height:260px;
|
||||
background:#212429; border:1px solid var(--border-2); border-radius:11px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
||||
.notes-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.notes-modal .pi svg { flex:0 0 auto; }
|
||||
.notes-modal .hist-title { color:var(--fg-1); font-size:14px; }
|
||||
.notes-modal .notes-file { flex:1; min-width:0; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
|
||||
.notes-modal .notes-hint { flex:0 0 auto; display:flex; align-items:center; gap:6px; font-size:11.5px; color:var(--fg-2); }
|
||||
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
|
||||
padding:14px 16px; color:var(--fg-1); font-family:var(--code-font); font-size:var(--code-size); line-height:20px; }
|
||||
.notes-input::placeholder { color:var(--fg-3); }
|
||||
|
||||
.help-modal { width:520px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
|
||||
.help-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
|
||||
.help-modal .pi svg { flex:0 0 auto; }
|
||||
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
|
||||
.help-list { max-height:62vh; overflow:auto; padding:8px 6px; }
|
||||
.help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
|
||||
.help-row:hover { background:var(--hover); }
|
||||
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
|
||||
.help-keys kbd { min-width:20px; text-align:center; }
|
||||
.help-label { font-size:12.5px; color:var(--fg-2); }
|
||||
|
||||
/* title-bar icon-only button (help ?) */
|
||||
.tb-icon { padding:5px 7px; }
|
||||
|
||||
/* confirm dialog (delete, etc.) */
|
||||
.confirm-modal { width:420px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:12px; box-shadow:0 24px 70px rgba(0,0,0,.55); padding:18px 20px; }
|
||||
.cf-title { font-size:14px; font-weight:600; color:var(--fg-0); }
|
||||
.cf-body { margin-top:8px; font-size:12.5px; line-height:1.45; color:var(--fg-2); font-family:var(--mono); word-break:break-all; }
|
||||
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
|
||||
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
|
||||
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
|
||||
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
||||
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
|
||||
.cf-yes.danger { background:var(--del); color:#fff; }
|
||||
.cf-yes.danger:hover { background:#e8797a; }
|
||||
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
|
||||
|
||||
/* search: active result column + file-name selection */
|
||||
.sc-head .col-kbd { margin-left:auto; opacity:.55; }
|
||||
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
|
||||
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
|
||||
.sc-infile.active .sc-head .scf-name { color:var(--accent); }
|
||||
.fres.sel { background:var(--accent-soft); box-shadow:inset 2px 0 0 var(--accent); }
|
||||
|
||||
@@ -8,6 +8,9 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal as XTerm } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { ContextMenu } from './overlays'
|
||||
import type { Menu } from './overlays'
|
||||
import { Icon } from './components'
|
||||
|
||||
let _lid = 0
|
||||
export const lid = (): number => ++_lid
|
||||
@@ -20,7 +23,8 @@ const THEME = {
|
||||
foreground: '#e6e8ea',
|
||||
cursor: '#4d8dff',
|
||||
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',
|
||||
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
|
||||
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',
|
||||
@@ -29,7 +33,14 @@ const THEME = {
|
||||
|
||||
export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElement {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [live, setLive] = useState(kind === 'agent')
|
||||
const termRef = useRef<XTerm | null>(null)
|
||||
const [, setLive] = useState(kind === 'agent')
|
||||
const [menu, setMenu] = useState<Menu | null>(null)
|
||||
// Best-effort "is the agent composer non-empty?" flag. A passed reference must
|
||||
// land on its own line, so we prepend a newline — UNLESS the composer is empty
|
||||
// (no leading blank line). We can't read the CLI's input buffer, so we infer:
|
||||
// typing a printable char marks it dirty, pressing Enter (submit) clears it.
|
||||
const composerDirty = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = window.helder
|
||||
@@ -48,9 +59,18 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
})
|
||||
termRef.current = term
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
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 */ }
|
||||
|
||||
let disposed = false
|
||||
@@ -61,7 +81,10 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
function onPaste(e: Event): void {
|
||||
if (kind !== 'agent' || !bridge || id < 0) return
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
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))
|
||||
if (kind === 'agent') window.addEventListener('agentPaste', onPaste)
|
||||
})
|
||||
@@ -95,19 +126,38 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
|
||||
offExit()
|
||||
if (kind === 'agent') window.removeEventListener('agentPaste', onPaste)
|
||||
if (bridge && id >= 0) bridge.pty.kill(id)
|
||||
termRef.current = null
|
||||
term.dispose()
|
||||
}
|
||||
// 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 (
|
||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }} onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}>
|
||||
<div className="term-head">
|
||||
<span className={'dot' + (live ? ' live' : '')}></span>
|
||||
<span className="lbl">{kind === 'agent' ? 'claude' : 'zsh'}</span>
|
||||
<span className="tag">{kind === 'agent' ? 'agent session' : '— shell'}</span>
|
||||
</div>
|
||||
<div className="term-pane" style={{ flex: 1, minHeight: 0 }}
|
||||
onMouseDown={() => hostRef.current?.querySelector('textarea')?.focus()}
|
||||
onContextMenu={onContextMenu}>
|
||||
<div className="term-xterm" ref={hostRef} />
|
||||
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,21 @@ export interface Change {
|
||||
add: number
|
||||
del: number
|
||||
deleted: boolean
|
||||
/** True when this row is the staged half of the file. A file that is staged
|
||||
* and then edited again produces two rows, one in each group. */
|
||||
staged: boolean
|
||||
/** Row identity. Path alone is no longer unique — see `staged`. */
|
||||
id: string
|
||||
}
|
||||
|
||||
/** Which half of a file's changes a diff view shows. `staged` is HEAD vs the
|
||||
* index, `unstaged` is the index vs disk. Absent means the whole file: HEAD vs
|
||||
* disk, which is what Original and Actual always show. */
|
||||
export type DiffSide = 'staged' | 'unstaged'
|
||||
|
||||
/** Row id for a git change. Keeps the two halves of one file apart. */
|
||||
export function rowId(path: string, staged: boolean): string {
|
||||
return (staged ? 's:' : 'w:') + path
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@@ -65,17 +80,23 @@ export interface ViewLine {
|
||||
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
|
||||
}
|
||||
|
||||
export type DiffMode = 'original' | 'updated' | 'diff'
|
||||
|
||||
/** Effective project settings (mirrors src/main/config.ts). */
|
||||
export interface HelderConfig {
|
||||
ai: { command: string; autoLaunch: boolean }
|
||||
editor: { autoSave: boolean; tabSize: number }
|
||||
git: { confirmDiscard: boolean }
|
||||
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
|
||||
files: { exclude: string[]; followGitignore: boolean }
|
||||
terminal: { shell: string | null }
|
||||
session: { restoreOnLaunch: boolean }
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: HelderConfig = {
|
||||
ai: { command: 'claude', autoLaunch: true },
|
||||
editor: { autoSave: false, tabSize: 4 },
|
||||
git: { confirmDiscard: true },
|
||||
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
|
||||
files: { exclude: [], followGitignore: true },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
45
sync_helder.sh
Executable file
45
sync_helder.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# sync_helder.sh — build Helder (arm64), install it into ~/Applications, and
|
||||
# drop a stable-named Helder.dmg alongside it.
|
||||
#
|
||||
# ./sync_helder.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# Run from the repo root (where this script lives), whatever the caller's cwd is.
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APPS_DIR="$HOME/Applications"
|
||||
APP_DEST="$APPS_DIR/Helder.app"
|
||||
DMG_DEST="$APPS_DIR/Helder.dmg"
|
||||
|
||||
echo "▶ Building Helder (arm64)…"
|
||||
npm run dist:mac
|
||||
|
||||
# Locate the freshly built artifacts.
|
||||
APP_SRC="dist/mac-arm64/Helder.app"
|
||||
DMG_SRC="$(ls -t dist/Helder-*-arm64.dmg 2>/dev/null | head -n1 || true)"
|
||||
|
||||
[ -d "$APP_SRC" ] || { echo "✗ Build output not found: $APP_SRC"; exit 1; }
|
||||
[ -n "$DMG_SRC" ] && [ -f "$DMG_SRC" ] || { echo "✗ No built .dmg found in dist/"; exit 1; }
|
||||
|
||||
mkdir -p "$APPS_DIR"
|
||||
|
||||
# Quit a running copy so we can replace the bundle cleanly.
|
||||
osascript -e 'tell application "Helder" to quit' >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
|
||||
echo "▶ Installing app → $APP_DEST"
|
||||
rm -rf "$APP_DEST"
|
||||
cp -R "$APP_SRC" "$APP_DEST"
|
||||
# Unsigned ad-hoc build: clear the quarantine flag so it launches without the
|
||||
# Gatekeeper "unidentified developer" prompt.
|
||||
xattr -dr com.apple.quarantine "$APP_DEST" 2>/dev/null || true
|
||||
|
||||
echo "▶ Copying installer → $DMG_DEST"
|
||||
cp -f "$DMG_SRC" "$DMG_DEST"
|
||||
|
||||
echo "✓ Done."
|
||||
echo " • Installed: $APP_DEST (launch from Spotlight)"
|
||||
echo " • Installer: $DMG_DEST (renamed from $(basename "$DMG_SRC"))"
|
||||
96
test/app-interactions.test.tsx
Normal file
96
test/app-interactions.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
return render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
}
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
// The tree opens fully collapsed, so expand each ancestor folder before reaching
|
||||
// a nested file. Each folder is clicked exactly once (a second click collapses).
|
||||
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
|
||||
for (const name of folders) {
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', name)
|
||||
if (!r) throw new Error(`folder ${name} not ready`)
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
}
|
||||
}
|
||||
|
||||
describe('Pass on to Agent', () => {
|
||||
it('inserts "<note> => <path:line>" via the agentPaste event', async () => {
|
||||
const received: string[] = []
|
||||
const handler = (e: Event): void => { received.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', handler)
|
||||
try {
|
||||
const c = renderApp()
|
||||
await expandTo(c, 'public', 'assets')
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = find(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow)
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
fireEvent.contextMenu(ta)
|
||||
const pass = await waitFor(() => {
|
||||
const item = find(c, '.ctx-item', 'Pass on reference')
|
||||
if (!item) throw new Error('menu not open')
|
||||
return item
|
||||
})
|
||||
fireEvent.click(pass)
|
||||
const input = await waitFor(() => {
|
||||
const i = c.querySelector<HTMLInputElement>('.pass-input')
|
||||
if (!i) throw new Error('popup not open')
|
||||
return i
|
||||
})
|
||||
fireEvent.change(input, { target: { value: 'look here' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
await waitFor(() => expect(received.length).toBeGreaterThan(0))
|
||||
expect(received[0]).toBe('look here => public/assets/store.js:1')
|
||||
} finally {
|
||||
window.removeEventListener('agentPaste', handler)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Stage + commit', () => {
|
||||
it('stages a file and commits via Shift+Enter (no commit button)', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = find(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!)
|
||||
// the commit button is intentionally gone — committing is keyboard-only
|
||||
expect(c.querySelector('.commit-btn')).toBeNull()
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
fireEvent.keyDown(msg, { key: 'Enter', shiftKey: true })
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
108
test/app.test.tsx
Normal file
108
test/app.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
// The terminals use xterm + ResizeObserver, which don't belong in a jsdom unit
|
||||
// test. Stub them — the rest of the workbench renders for real against the mock.
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
// jsdom gaps used by the tree/tab code.
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function renderApp(): HTMLElement {
|
||||
const { container } = render(
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>,
|
||||
)
|
||||
return container
|
||||
}
|
||||
|
||||
function rowWithText(container: HTMLElement, selector: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => el.textContent?.includes(text))
|
||||
}
|
||||
// The tree opens fully collapsed, so expand each ancestor folder before reaching
|
||||
// a nested file. Each folder is clicked exactly once (a second click collapses).
|
||||
async function expandTo(c: HTMLElement, ...folders: string[]): Promise<void> {
|
||||
for (const name of folders) {
|
||||
const row = await waitFor(() => {
|
||||
const r = rowWithText(c, '.tree-row', name)
|
||||
if (!r) throw new Error(`folder ${name} not ready`)
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
}
|
||||
}
|
||||
|
||||
describe('App (mock data, jsdom)', () => {
|
||||
it('renders the four-column workbench with the git change list', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
expect(c.querySelector('.workbench')).toBeTruthy()
|
||||
expect(c.querySelector('.commit-box')).toBeTruthy() // git panel
|
||||
expect(c.querySelector('.tree-body')).toBeTruthy() // explorer
|
||||
// no file open yet
|
||||
expect(c.textContent).toContain('No file open')
|
||||
})
|
||||
|
||||
it('opens a changed file from the git panel into the diff view', async () => {
|
||||
const c = renderApp()
|
||||
const row = await waitFor(() => {
|
||||
const r = rowWithText(c, '.git-row', 'UserController.php')
|
||||
if (!r) throw new Error('row not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
// no tabs anymore — the file opens straight into the editor's diff toolbar,
|
||||
// and its path shows in the title-bar breadcrumb.
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
||||
expect(rowWithText(c, '.tb-crumb', 'UserController.php')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('makes an edited buffer dirty (breadcrumb dot)', async () => {
|
||||
const c = renderApp()
|
||||
await expandTo(c, 'public', 'assets')
|
||||
const treeRow = await waitFor(() => {
|
||||
const r = rowWithText(c, '.tree-row', 'store.js')
|
||||
if (!r) throw new Error('tree not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(treeRow) // unchanged file → editable "code" mode
|
||||
const ta = await waitFor(() => {
|
||||
const t = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!t) throw new Error('editor not ready')
|
||||
return t
|
||||
})
|
||||
expect(c.querySelector('.tb-dirty')).toBeNull()
|
||||
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
||||
await waitFor(() => expect(c.querySelector('.tb-dirty')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('searches file contents from the search modal', async () => {
|
||||
const c = renderApp()
|
||||
await waitFor(() => expect(rowWithText(c, '.git-row', 'UserController.php')).toBeTruthy())
|
||||
const searchBtn = rowWithText(c, '.tb-btn', 'Search')!
|
||||
fireEvent.click(searchBtn)
|
||||
const modal = await waitFor(() => {
|
||||
const m = c.querySelector('.search-modal')
|
||||
if (!m) throw new Error('modal not open')
|
||||
return m as HTMLElement
|
||||
})
|
||||
const input = within(modal).getByPlaceholderText(/Search this file/i)
|
||||
fireEvent.change(input, { target: { value: 'balance' } })
|
||||
await waitFor(() => expect(modal.querySelectorAll('.sr-file').length).toBeGreaterThan(0))
|
||||
})
|
||||
})
|
||||
54
test/config.test.ts
Normal file
54
test/config.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { DEFAULTS, getConfig, getThemeCss, resolveConfig } from '../src/main/config'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
it('creates config.default.json + theme.css and yields defaults for a fresh project', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def).toEqual(DEFAULTS)
|
||||
const theme = await readFile(join(dir, '.helder/theme.css'), 'utf8')
|
||||
expect(theme).toContain('--code-font')
|
||||
expect(getConfig().ai.command).toBe('claude')
|
||||
expect(getThemeCss()).toContain('Helder theme')
|
||||
})
|
||||
|
||||
it('deep-merges a sparse config.json over defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'),
|
||||
JSON.stringify({ ai: { command: 'claude --model opus' }, editor: { tabSize: 2 } }))
|
||||
await resolveConfig(dir)
|
||||
|
||||
const c = getConfig()
|
||||
expect(c.ai.command).toBe('claude --model opus') // overridden
|
||||
expect(c.ai.autoLaunch).toBe(true) // default kept
|
||||
expect(c.editor.tabSize).toBe(2) // overridden
|
||||
expect(c.editor.autoSave).toBe(false) // default kept
|
||||
expect(c.git.confirmDiscard).toBe(true) // default kept
|
||||
})
|
||||
|
||||
it('always regenerates config.default.json with full built-in defaults', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/config.json'), JSON.stringify({ ai: { command: 'x' } }))
|
||||
await resolveConfig(dir)
|
||||
const def = JSON.parse(await readFile(join(dir, '.helder/config.default.json'), 'utf8'))
|
||||
expect(def.ai.command).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not overwrite an existing theme.css', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-cfg-'))
|
||||
await resolveConfig(dir)
|
||||
await writeFile(join(dir, '.helder/theme.css'), ':root{--accent:#ff0000}')
|
||||
await resolveConfig(dir)
|
||||
expect(getThemeCss()).toContain('#ff0000')
|
||||
})
|
||||
})
|
||||
71
test/diff.test.ts
Normal file
71
test/diff.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDiff, makeDiff } from '../src/renderer/src/diff'
|
||||
|
||||
describe('buildDiff', () => {
|
||||
it('reports no changes for identical text', () => {
|
||||
const d = buildDiff('a\nb\nc\n', 'a\nb\nc\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.rows.every((r) => r.sign === ' ')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts a single changed line as one add + one del', () => {
|
||||
const d = buildDiff('a\nb\nc', 'a\nB\nc')
|
||||
expect(d.add).toBe(1)
|
||||
expect(d.del).toBe(1)
|
||||
const signs = d.rows.map((r) => r.sign).join('')
|
||||
expect(signs).toContain('-')
|
||||
expect(signs).toContain('+')
|
||||
})
|
||||
|
||||
it('treats an empty original as all additions (new file)', () => {
|
||||
const d = buildDiff('', 'x\ny')
|
||||
expect(d.add).toBe(2)
|
||||
expect(d.del).toBe(0)
|
||||
expect(d.left).toHaveLength(0)
|
||||
expect(d.right).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('treats an empty updated as all deletions (deleted file)', () => {
|
||||
const d = buildDiff('x\ny\nz', '')
|
||||
expect(d.del).toBe(3)
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.right).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks the changed line on both sides', () => {
|
||||
const d = buildDiff('keep\nold\nkeep', 'keep\nnew\nkeep')
|
||||
expect(d.left.find((l) => l.text === 'old')?.mark).toBe('del')
|
||||
expect(d.right.find((l) => l.text === 'new')?.mark).toBe('add')
|
||||
expect(d.left.find((l) => l.text === 'keep')?.mark).toBeNull()
|
||||
})
|
||||
|
||||
it('aligns split rows: same lines pair, changes stack into the gap', () => {
|
||||
const d = buildDiff('a\nold\nb', 'a\nnew\nb')
|
||||
// every split row has at least one side
|
||||
expect(d.split.every((r) => r.l || r.r)).toBe(true)
|
||||
// the matched 'a' and 'b' lines pair on both sides
|
||||
const paired = d.split.filter((r) => r.l && r.r && r.l.text === r.r.text)
|
||||
expect(paired.map((r) => r.l!.text)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('ignores a single trailing newline difference', () => {
|
||||
const d = buildDiff('a\nb', 'a\nb\n')
|
||||
expect(d.add).toBe(0)
|
||||
expect(d.del).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeDiff', () => {
|
||||
it('flags added / deleted and carries the text pair', () => {
|
||||
const added = makeDiff('A', '', 'hi')
|
||||
expect(added.added).toBe(true)
|
||||
expect(added.deleted).toBe(false)
|
||||
expect(added.original).toBe('')
|
||||
expect(added.updated).toBe('hi')
|
||||
|
||||
const deleted = makeDiff('D', 'bye', '')
|
||||
expect(deleted.deleted).toBe(true)
|
||||
expect(deleted.added).toBe(false)
|
||||
})
|
||||
})
|
||||
57
test/editor-modes.test.tsx
Normal file
57
test/editor-modes.test.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
async function openChanged(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
return c
|
||||
}
|
||||
|
||||
describe('Editor view modes', () => {
|
||||
it('Actual mode is an editable buffer; Original is read-only', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||
|
||||
fireEvent.click(find(c, '.seg button', 'Original')!)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
|
||||
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(c.querySelector('.split-btn')!)
|
||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
|
||||
expect(c.querySelector('.split-pane.left')).toBeTruthy()
|
||||
expect(c.querySelector('.split-pane.right')).toBeTruthy()
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
|
||||
})
|
||||
})
|
||||
83
test/editor-tab.test.tsx
Normal file
83
test/editor-tab.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||
})
|
||||
afterEach(() => { cleanup(); localStorage.clear() })
|
||||
|
||||
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
// Open a changed file and switch to the writable buffer.
|
||||
async function openEditor(): Promise<HTMLTextAreaElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
const row = await waitFor(() => {
|
||||
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||
if (!r) throw new Error('git not ready')
|
||||
return r
|
||||
})
|
||||
fireEvent.click(row)
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||
return await waitFor(() => {
|
||||
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||
if (!ta) throw new Error('no buffer')
|
||||
return ta
|
||||
})
|
||||
}
|
||||
|
||||
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
|
||||
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
|
||||
}
|
||||
|
||||
describe('Tab in the code editor', () => {
|
||||
it('inserts four spaces at the caret instead of moving focus', async () => {
|
||||
const ta = await openEditor()
|
||||
const before = ta.value
|
||||
ta.setSelectionRange(0, 0)
|
||||
|
||||
// fireEvent returns false when a handler called preventDefault, which is
|
||||
// what stops the browser tabbing focus over to the agent column.
|
||||
expect(tab(ta)).toBe(false)
|
||||
await waitFor(() => expect(ta.value).toBe(' ' + before))
|
||||
})
|
||||
|
||||
it('indents every line a multi-line selection touches', async () => {
|
||||
const ta = await openEditor()
|
||||
const lines = ta.value.split('\n')
|
||||
// Select from inside line 1 to inside line 2.
|
||||
ta.setSelectionRange(1, lines[0].length + 2)
|
||||
|
||||
tab(ta)
|
||||
await waitFor(() => {
|
||||
const now = ta.value.split('\n')
|
||||
expect(now[0]).toBe(' ' + lines[0])
|
||||
expect(now[1]).toBe(' ' + lines[1])
|
||||
expect(now[2]).toBe(lines[2])
|
||||
})
|
||||
})
|
||||
|
||||
it('Shift+Tab outdents the current line', async () => {
|
||||
const ta = await openEditor()
|
||||
ta.setSelectionRange(0, 0)
|
||||
tab(ta)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
|
||||
|
||||
ta.setSelectionRange(6, 6)
|
||||
expect(tab(ta, true)).toBe(false)
|
||||
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
|
||||
})
|
||||
})
|
||||
152
test/fs.test.ts
Normal file
152
test/fs.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { buildTreeFromPaths, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function fixture(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await mkdir(join(d, 'src'), { recursive: true })
|
||||
await mkdir(join(d, 'node_modules/pkg'), { recursive: true })
|
||||
await mkdir(join(d, '.git'), { recursive: true })
|
||||
await writeFile(join(d, 'src', 'a.ts'), 'export const a = 1\n')
|
||||
await writeFile(join(d, 'README.md'), '# hi\n')
|
||||
await writeFile(join(d, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
|
||||
await writeFile(join(d, '.git', 'HEAD'), 'ref: refs/heads/main\n')
|
||||
await writeFile(join(d, 'logo.bin'), Buffer.from([0x00, 0x01, 0x02, 0x00, 0x99]))
|
||||
return d
|
||||
}
|
||||
|
||||
describe('readTree', () => {
|
||||
it('lists dirs before files, ignoring node_modules and .git', async () => {
|
||||
dir = await fixture()
|
||||
const tree = await readTree(dir)
|
||||
const top = (tree.children || []).map((c) => c.name)
|
||||
expect(top).not.toContain('node_modules')
|
||||
expect(top).not.toContain('.git')
|
||||
expect(top).toContain('src')
|
||||
// dirs first
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('README.md'))
|
||||
expect(top.indexOf('src')).toBeLessThan(top.indexOf('logo.bin'))
|
||||
})
|
||||
|
||||
it('shows an empty folder (no files yet) that rg --files can not emit', async () => {
|
||||
dir = await fixture()
|
||||
await mkdir(join(dir, 'empty'), { recursive: true })
|
||||
await mkdir(join(dir, 'src', 'nested', 'deep'), { recursive: true }) // file-empty nested chain
|
||||
const tree = await readTree(dir)
|
||||
const top = (tree.children || []).map((c) => c.name)
|
||||
expect(top).toContain('empty')
|
||||
const empty = (tree.children || []).find((c) => c.name === 'empty')!
|
||||
expect(empty.type).toBe('dir')
|
||||
// the nested empty chain shows under the (file-bearing) src folder
|
||||
const src = (tree.children || []).find((c) => c.name === 'src')!
|
||||
const nested = (src.children || []).find((c) => c.name === 'nested')!
|
||||
expect(nested.type).toBe('dir')
|
||||
expect((nested.children || []).map((c) => c.name)).toEqual(['deep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readDirChildren', () => {
|
||||
it('returns a single folder\'s children (root and subdir), ignoring node_modules', async () => {
|
||||
dir = await fixture()
|
||||
const top = (await readDirChildren(dir, '')).map((c) => c.name)
|
||||
expect(top).toContain('src')
|
||||
expect(top).toContain('README.md')
|
||||
expect(top).not.toContain('node_modules')
|
||||
const src = await readDirChildren(dir, 'src')
|
||||
expect(src.map((c) => c.name)).toEqual(['a.ts'])
|
||||
expect(src[0].path).toBe('src/a.ts')
|
||||
})
|
||||
|
||||
it('shows an empty subfolder created since the initial tree read', async () => {
|
||||
dir = await fixture()
|
||||
await mkdir(join(dir, 'src', 'fresh'), { recursive: true })
|
||||
const src = await readDirChildren(dir, 'src')
|
||||
const fresh = src.find((c) => c.name === 'fresh')
|
||||
expect(fresh?.type).toBe('dir')
|
||||
expect(fresh?.path).toBe('src/fresh')
|
||||
})
|
||||
|
||||
it('reflects files added/removed since the initial tree read', async () => {
|
||||
dir = await fixture()
|
||||
await writeFile(join(dir, 'src', 'b.ts'), 'export const b = 2\n')
|
||||
await rm(join(dir, 'src', 'a.ts'))
|
||||
expect((await readDirChildren(dir, 'src')).map((c) => c.name)).toEqual(['b.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('readAll', () => {
|
||||
it('indexes text files, skipping ignored dirs and binary files', async () => {
|
||||
dir = await fixture()
|
||||
const files = await readAll(dir)
|
||||
const keys = Object.keys(files)
|
||||
expect(keys).toContain('src/a.ts')
|
||||
expect(keys).toContain('README.md')
|
||||
expect(keys.some((k) => k.includes('node_modules'))).toBe(false)
|
||||
expect(keys).not.toContain('logo.bin') // binary skipped
|
||||
expect(files['src/a.ts']).toContain('export const a')
|
||||
})
|
||||
|
||||
it('shows .gitignored files in a repo (files.followGitignore default off)', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await simpleGit(dir).init()
|
||||
await writeFile(join(dir, '.gitignore'), 'secret.txt\n')
|
||||
await writeFile(join(dir, 'secret.txt'), 'shh')
|
||||
await writeFile(join(dir, 'keep.txt'), 'ok')
|
||||
const keys = Object.keys(await readAll(dir))
|
||||
expect(keys).toContain('keep.txt')
|
||||
expect(keys).toContain('secret.txt') // gitignore not followed by default → shown
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTreeFromPaths', () => {
|
||||
it('nests paths with dirs before files, alphabetical', () => {
|
||||
const t = buildTreeFromPaths('proj', ['src/b.ts', 'src/a.ts', 'README.md', 'src/util/x.ts'])
|
||||
expect((t.children || []).map((c) => c.name)).toEqual(['src', 'README.md'])
|
||||
const src = (t.children || []).find((c) => c.name === 'src')!
|
||||
expect((src.children || []).map((c) => c.name)).toEqual(['util', 'a.ts', 'b.ts'])
|
||||
expect((src.children || []).find((c) => c.name === 'util')!.path).toBe('src/util')
|
||||
})
|
||||
|
||||
it('forces dirPaths in as empty folders, deduped against file-derived dirs', () => {
|
||||
const t = buildTreeFromPaths('proj', ['src/a.ts'], ['empty', 'src/sub', 'src'])
|
||||
const top = (t.children || []).map((c) => c.name)
|
||||
expect(top).toEqual(['empty', 'src']) // dirs alphabetical, both present once
|
||||
const src = (t.children || []).find((c) => c.name === 'src')!
|
||||
expect((src.children || []).map((c) => c.name)).toEqual(['sub', 'a.ts'])
|
||||
expect((src.children || []).find((c) => c.name === 'sub')!.type).toBe('dir')
|
||||
})
|
||||
})
|
||||
|
||||
describe('read/write round-trip', () => {
|
||||
it('writes then reads the same content', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'note.txt', 'hello world\n')
|
||||
expect(await readProjectFile(dir, 'note.txt')).toBe('hello world\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProjectFile', () => {
|
||||
it('creates an empty file and makes missing parent folders', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await createProjectFile(dir, 'src/new/fresh.ts')
|
||||
expect(await readProjectFile(dir, 'src/new/fresh.ts')).toBe('')
|
||||
})
|
||||
|
||||
it('refuses to overwrite an existing file', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await writeProjectFile(dir, 'keep.txt', 'precious\n')
|
||||
await expect(createProjectFile(dir, 'keep.txt')).rejects.toThrow()
|
||||
expect(await readProjectFile(dir, 'keep.txt')).toBe('precious\n')
|
||||
})
|
||||
|
||||
it('refuses to escape the project root', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
|
||||
await expect(createProjectFile(dir, '../escape.txt')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
25
test/fuzzy.test.ts
Normal file
25
test/fuzzy.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fuzzy } from '../src/renderer/src/fuzzy'
|
||||
|
||||
describe('fuzzy', () => {
|
||||
it('matches greedily from the left (first c, then a, then t)', () => {
|
||||
expect(fuzzy('cat', 'concatenate')).toEqual([0, 4, 5])
|
||||
})
|
||||
|
||||
it('matches a non-contiguous subsequence', () => {
|
||||
expect(fuzzy('uc', 'UserController')).toEqual([0, 4])
|
||||
})
|
||||
|
||||
it('is case-insensitive (skips the e to reach r)', () => {
|
||||
expect(fuzzy('USR', 'user')).toEqual([0, 1, 3])
|
||||
})
|
||||
|
||||
it('returns null when not a subsequence', () => {
|
||||
expect(fuzzy('xyz', 'abc')).toBeNull()
|
||||
expect(fuzzy('ca', 'abc')).toBeNull() // order matters
|
||||
})
|
||||
|
||||
it('returns an empty index array for an empty query', () => {
|
||||
expect(fuzzy('', 'anything')).toEqual([])
|
||||
})
|
||||
})
|
||||
168
test/git-two-rows.test.tsx
Normal file
168
test/git-two-rows.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A file can be staged and then edited again. Git calls that "MM": two rows,
|
||||
// one per group. These tests drive the renderer with such a payload and check
|
||||
// that Diff follows the row you clicked, while Original and Actual do not.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
const HEAD = 'a\nb\nc\n'
|
||||
const INDEX = 'a\nSTAGED\nc\n'
|
||||
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
|
||||
|
||||
/** Minimal preload bridge: enough for the store to boot with real git rows. */
|
||||
function stubBridge(): void {
|
||||
const noop = (): void => {}
|
||||
const off = (): (() => void) => noop
|
||||
;(window as unknown as { helder: unknown }).helder = {
|
||||
platform: 'darwin',
|
||||
clipboard: { writeText: noop, readText: () => '' },
|
||||
project: {
|
||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||
recent: async () => [],
|
||||
},
|
||||
fs: {
|
||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
|
||||
readDir: async () => [],
|
||||
files: async () => ({ 'demo.txt': DISK }),
|
||||
read: async () => DISK,
|
||||
imageDataUrl: async () => '',
|
||||
write: async () => {},
|
||||
delete: async () => {},
|
||||
create: async () => {},
|
||||
mkdir: async () => {},
|
||||
},
|
||||
shell: { reveal: noop },
|
||||
notes: { read: async () => '', write: async () => {} },
|
||||
git: {
|
||||
// Exactly what git-service now returns for porcelain "MM".
|
||||
load: async () => ({
|
||||
branch: 'main',
|
||||
changes: [
|
||||
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
|
||||
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
|
||||
],
|
||||
}),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||
recent: { get: async () => [], set: async () => {} },
|
||||
search: { content: async () => [], files: async () => [] },
|
||||
dialog: { unsavedClose: async () => 'cancel' },
|
||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||
onProjectChanged: off,
|
||||
onConfigChanged: off,
|
||||
onRefresh: off,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(stubBridge)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
delete (window as unknown as { helder?: unknown }).helder
|
||||
})
|
||||
|
||||
/** Row text of the view currently on screen. */
|
||||
function viewText(c: HTMLElement): string {
|
||||
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
|
||||
}
|
||||
|
||||
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
|
||||
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
|
||||
const head = heads.find((h) => h.textContent?.startsWith(label))!
|
||||
const rows: HTMLElement[] = []
|
||||
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
|
||||
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
|
||||
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => {
|
||||
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
describe('a file that is staged and then edited again', () => {
|
||||
it('shows up in both groups', async () => {
|
||||
const c = await boot()
|
||||
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||
})
|
||||
|
||||
it('Diff on the staged row compares HEAD with the staged copy', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Staged Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
const text = viewText(c)
|
||||
expect(text).toContain('b')
|
||||
expect(text).toContain('STAGED')
|
||||
// The later edit is not part of what is staged, so it must not show here.
|
||||
expect(text).not.toContain('AFTER-STAGING')
|
||||
})
|
||||
|
||||
it('Diff on the unstaged row compares the staged copy with disk', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
const text = viewText(c)
|
||||
expect(text).toContain('AFTER-STAGING')
|
||||
// 'b' was already replaced before staging, so this half must not mention it.
|
||||
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
|
||||
})
|
||||
|
||||
it('labels which pair the diff is comparing', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Staged Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
|
||||
fireEvent.click(group(c, 'Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
|
||||
})
|
||||
|
||||
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
|
||||
const c = await boot()
|
||||
for (const label of ['Staged Changes', 'Changes'] as const) {
|
||||
fireEvent.click(group(c, label)[0])
|
||||
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||
|
||||
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
|
||||
fireEvent.click(original)
|
||||
await waitFor(() => expect(viewText(c)).toContain('b'))
|
||||
expect(viewText(c)).not.toContain('AFTER-STAGING')
|
||||
|
||||
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
|
||||
fireEvent.click(actual)
|
||||
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
|
||||
}
|
||||
})
|
||||
|
||||
it('only the row you opened is highlighted', async () => {
|
||||
const c = await boot()
|
||||
fireEvent.click(group(c, 'Changes')[0])
|
||||
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
|
||||
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
|
||||
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
|
||||
})
|
||||
})
|
||||
166
test/git.test.ts
Normal file
166
test/git.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { classify, discard, load, stage } from '../src/main/git-service'
|
||||
|
||||
describe('classify', () => {
|
||||
it('reads the index code as staged, working code as unstaged', () => {
|
||||
expect(classify('M', ' ')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }])
|
||||
expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }])
|
||||
expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }])
|
||||
expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }])
|
||||
})
|
||||
it('treats untracked as a new (A) unstaged file', () => {
|
||||
expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }])
|
||||
})
|
||||
it('splits a staged-then-edited file into two rows', () => {
|
||||
expect(classify('M', 'M')).toEqual([
|
||||
{ letter: 'M', staged: true },
|
||||
{ letter: 'M', staged: false },
|
||||
])
|
||||
expect(classify('A', 'M')).toEqual([
|
||||
{ letter: 'A', staged: true },
|
||||
{ letter: 'M', staged: false },
|
||||
])
|
||||
expect(classify('M', 'D')).toEqual([
|
||||
{ letter: 'M', staged: true },
|
||||
{ letter: 'D', staged: false },
|
||||
])
|
||||
})
|
||||
it('keeps a merge conflict as one row', () => {
|
||||
expect(classify('U', 'U')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify('A', 'A')).toEqual([{ letter: 'M', staged: true }])
|
||||
expect(classify('D', 'D')).toEqual([{ letter: 'M', staged: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('load (integration against a temp repo)', () => {
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
async function repo(): Promise<string> {
|
||||
const d = await mkdtemp(join(tmpdir(), 'helder-git-'))
|
||||
const g = simpleGit(d)
|
||||
await g.init()
|
||||
await g.addConfig('user.email', 't@example.com')
|
||||
await g.addConfig('user.name', 'Test')
|
||||
await g.addConfig('commit.gpgsign', 'false')
|
||||
await writeFile(join(d, 'a.txt'), '1\n2\n3\n')
|
||||
await g.add('.')
|
||||
await g.commit('init')
|
||||
return d
|
||||
}
|
||||
|
||||
it('returns null for a non-repo directory', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-nogit-'))
|
||||
expect(await load(dir)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a modified file with HEAD-vs-worktree text', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nX\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res).not.toBeNull()
|
||||
expect(res!.branch).toBeTruthy()
|
||||
const a = res!.changes.find((c) => c.path === 'a.txt')
|
||||
expect(a?.status).toBe('M')
|
||||
expect(a?.staged).toBe(false)
|
||||
expect(a?.original).toBe('1\n2\n3\n')
|
||||
expect(a?.updated).toBe('1\nX\n3\n')
|
||||
})
|
||||
|
||||
it('reports an untracked file as new (A), original empty', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
const res = await load(dir)
|
||||
const n = res!.changes.find((c) => c.path === 'new.txt')
|
||||
expect(n?.status).toBe('A')
|
||||
expect(n?.staged).toBe(false)
|
||||
expect(n?.original).toBe('')
|
||||
expect(n?.updated).toBe('fresh\n')
|
||||
})
|
||||
|
||||
it('reflects staging', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a staged-then-edited file in both groups, each with its own pair', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n4\n')
|
||||
const res = await load(dir)
|
||||
const rows = res!.changes.filter((c) => c.path === 'a.txt')
|
||||
expect(rows).toHaveLength(2)
|
||||
|
||||
// Staged row: HEAD -> index. It must NOT include the newer edit.
|
||||
const s = rows.find((c) => c.staged)!
|
||||
expect(s.original).toBe('1\n2\n3\n')
|
||||
expect(s.updated).toBe('1\nSTAGED\n3\n')
|
||||
|
||||
// Unstaged row: index -> disk. Only the newer edit.
|
||||
const w = rows.find((c) => !c.staged)!
|
||||
expect(w.original).toBe('1\nSTAGED\n3\n')
|
||||
expect(w.updated).toBe('1\nSTAGED\n3\n4\n')
|
||||
})
|
||||
|
||||
it('gives a staged-new file that was edited again both rows', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'fresh.txt'), 'one\n')
|
||||
await stage(dir, ['fresh.txt'])
|
||||
await writeFile(join(dir, 'fresh.txt'), 'one\ntwo\n')
|
||||
const res = await load(dir)
|
||||
const rows = res!.changes.filter((c) => c.path === 'fresh.txt')
|
||||
expect(rows.map((r) => [r.status, r.staged])).toEqual([['A', true], ['M', false]])
|
||||
expect(rows[0].original).toBe('')
|
||||
expect(rows[0].updated).toBe('one\n')
|
||||
expect(rows[1].original).toBe('one\n')
|
||||
expect(rows[1].updated).toBe('one\ntwo\n')
|
||||
})
|
||||
|
||||
it('keeps one row when a file is only staged', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
|
||||
await stage(dir, ['a.txt'])
|
||||
const rows = (await load(dir))!.changes.filter((c) => c.path === 'a.txt')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].staged).toBe(true)
|
||||
expect(rows[0].original).toBe('1\n2\n3\n')
|
||||
expect(rows[0].updated).toBe('1\n2\n3\n4\n')
|
||||
})
|
||||
|
||||
it('discard reverts a modified tracked file to HEAD', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')
|
||||
await discard(dir, ['a.txt'])
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('1\n2\n3\n')
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'a.txt')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('discard removes a new (untracked) file from disk', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'new.txt'), 'fresh\n')
|
||||
await discard(dir, ['new.txt'])
|
||||
expect(existsSync(join(dir, 'new.txt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('discard removes a staged-new file', async () => {
|
||||
dir = await repo()
|
||||
await writeFile(join(dir, 'staged-new.txt'), 'x\n')
|
||||
await stage(dir, ['staged-new.txt'])
|
||||
await discard(dir, ['staged-new.txt'])
|
||||
expect(existsSync(join(dir, 'staged-new.txt'))).toBe(false)
|
||||
const res = await load(dir)
|
||||
expect(res!.changes.find((c) => c.path === 'staged-new.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
60
test/highlight.test.ts
Normal file
60
test/highlight.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { HL } from '../src/renderer/src/highlight'
|
||||
|
||||
describe('HL.ext', () => {
|
||||
it('extracts a normal extension', () => {
|
||||
expect(HL.ext('src/a/b.php')).toBe('php')
|
||||
expect(HL.ext('x.TSX')).toBe('tsx')
|
||||
})
|
||||
it('treats dotfiles like .env specially', () => {
|
||||
expect(HL.ext('.env')).toBe('env')
|
||||
expect(HL.ext('config/.env.local')).toBe('env')
|
||||
})
|
||||
it('returns empty for no extension', () => {
|
||||
expect(HL.ext('Makefile')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.langFor / langLabel', () => {
|
||||
it('maps known extensions to Prism languages', () => {
|
||||
expect(HL.langFor('a.php')).toBe('php')
|
||||
expect(HL.langFor('a.ts')).toBe('typescript')
|
||||
expect(HL.langFor('a.py')).toBe('python')
|
||||
expect(HL.langFor('a.unknownext')).toBeNull()
|
||||
})
|
||||
it('produces human labels', () => {
|
||||
expect(HL.langLabel('a.php')).toBe('PHP')
|
||||
expect(HL.langLabel('a.tsx')).toBe('TypeScript')
|
||||
expect(HL.langLabel('Makefile')).toBe('Plain Text')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.iconFor', () => {
|
||||
it('uses name-specific icons', () => {
|
||||
expect(HL.iconFor('composer.json').t).toBe('co')
|
||||
expect(HL.iconFor('package.json').t).toBe('pk')
|
||||
})
|
||||
it('falls back to extension icons', () => {
|
||||
expect(HL.iconFor('x.php').c).toBe('#a78bdb')
|
||||
})
|
||||
it('falls back to first two letters for unknown types', () => {
|
||||
expect(HL.iconFor('weird.zzz').t).toBe('we')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.escapeHtml', () => {
|
||||
it('escapes html-significant characters', () => {
|
||||
expect(HL.escapeHtml('<a> & </a>')).toBe('<a> & </a>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HL.hlText (Prism)', () => {
|
||||
it('wraps php keywords in token spans (markup-templating loaded first)', () => {
|
||||
const out = HL.hlText('<?php class A {}', 'php')
|
||||
expect(out).toContain('token')
|
||||
expect(out).toContain('class')
|
||||
})
|
||||
it('escapes when no grammar is available', () => {
|
||||
expect(HL.hlText('<x>', null)).toBe('<x>')
|
||||
})
|
||||
})
|
||||
151
test/logger.test.ts
Normal file
151
test/logger.test.ts
Normal 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
73
test/markdown.test.ts
Normal 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('<script>')
|
||||
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
200
test/notes.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The project note: ⌘N opens it, the text lands in <project>/.notes.txt, and it
|
||||
// is written whenever the window loses focus.
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
let stored = ''
|
||||
let writes: string[] = []
|
||||
|
||||
function stubBridge(): void {
|
||||
const noop = (): void => {}
|
||||
const off = (): (() => void) => noop
|
||||
;(window as unknown as { helder: unknown }).helder = {
|
||||
platform: 'darwin',
|
||||
clipboard: { writeText: noop, readText: () => '' },
|
||||
project: {
|
||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||
recent: async () => [],
|
||||
},
|
||||
fs: {
|
||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||
create: async () => {}, mkdir: async () => {},
|
||||
},
|
||||
shell: { reveal: noop },
|
||||
notes: {
|
||||
read: async () => stored,
|
||||
write: async (t: string) => { stored = t; writes.push(t) },
|
||||
},
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||
recent: { get: async () => [], set: async () => {} },
|
||||
search: { content: async () => [], files: async () => [] },
|
||||
dialog: { unsavedClose: async () => 'cancel' },
|
||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(() => { stored = ''; writes = []; stubBridge() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
delete (window as unknown as { helder?: unknown }).helder
|
||||
})
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
function pressCmdN(): void {
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', metaKey: true })) })
|
||||
}
|
||||
|
||||
/** Open the note. The shortcut is registered in an effect, which React may not
|
||||
* have flushed yet when the first paint lands, so keep pressing until it takes.
|
||||
* A second ⌘N with the overlay already open is a no-op. */
|
||||
async function openNote(c: HTMLElement): Promise<HTMLTextAreaElement> {
|
||||
await waitFor(() => {
|
||||
pressCmdN()
|
||||
if (!c.querySelector('.notes-modal')) throw new Error('note not open')
|
||||
})
|
||||
return c.querySelector('.notes-input') as HTMLTextAreaElement
|
||||
}
|
||||
|
||||
function blurWindow(): void {
|
||||
act(() => { window.dispatchEvent(new Event('blur')) })
|
||||
}
|
||||
|
||||
describe('project note', () => {
|
||||
it('⌘N opens the note overlay', async () => {
|
||||
const c = await boot()
|
||||
expect(c.querySelector('.notes-modal')).toBeNull()
|
||||
await openNote(c)
|
||||
expect(c.querySelector('.notes-modal .notes-file')?.textContent).toBe('.notes.txt')
|
||||
})
|
||||
|
||||
it('loads the note that is already on disk', async () => {
|
||||
stored = 'earlier thoughts\n'
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
await waitFor(() => expect(ta.value).toBe('earlier thoughts\n'))
|
||||
})
|
||||
|
||||
it('writes the note when the window loses focus', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'buy milk' } })
|
||||
|
||||
expect(writes).toEqual([]) // nothing written while typing
|
||||
blurWindow()
|
||||
await waitFor(() => expect(writes).toEqual(['buy milk']))
|
||||
expect(stored).toBe('buy milk')
|
||||
})
|
||||
|
||||
it('does not rewrite the file when nothing changed', async () => {
|
||||
stored = 'unchanged'
|
||||
await boot()
|
||||
await waitFor(() => expect(stored).toBe('unchanged'))
|
||||
blurWindow()
|
||||
blurWindow()
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('Esc closes the note and saves it right away', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'quick capture' } })
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
await waitFor(() => expect(writes).toEqual(['quick capture']))
|
||||
})
|
||||
|
||||
it('⌘P passes the note to the agent, then saves and closes it', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'refactor the policy' } })
|
||||
|
||||
const seen: string[] = []
|
||||
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', onPaste)
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||
window.removeEventListener('agentPaste', onPaste)
|
||||
|
||||
expect(seen).toEqual(['refactor the policy'])
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
await waitFor(() => expect(writes).toEqual(['refactor the policy']))
|
||||
})
|
||||
|
||||
it('⌘P does nothing when the note is empty', async () => {
|
||||
const c = await boot()
|
||||
await openNote(c)
|
||||
|
||||
const seen: string[] = []
|
||||
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||
window.addEventListener('agentPaste', onPaste)
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||
window.removeEventListener('agentPaste', onPaste)
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(c.querySelector('.notes-modal')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('the header shows the pass-to-agent hint', async () => {
|
||||
const c = await boot()
|
||||
await openNote(c)
|
||||
const hint = c.querySelector('.notes-modal .notes-hint')
|
||||
expect(hint?.textContent).toContain('To agent')
|
||||
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
|
||||
})
|
||||
|
||||
it('the title bar carries the ⌘N note action', async () => {
|
||||
const c = await boot()
|
||||
const btn = [...c.querySelectorAll('.titlebar .tb-btn')]
|
||||
.find((b) => b.textContent?.includes('Note')) as HTMLButtonElement | undefined
|
||||
expect(btn?.querySelector('kbd')?.textContent).toBe('⌘N')
|
||||
expect(btn?.className).not.toContain('on')
|
||||
|
||||
act(() => { btn?.click() })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).not.toBeNull())
|
||||
// The action reads as "on" while the note is open, like the other toggles.
|
||||
expect(btn?.className).toContain('on')
|
||||
})
|
||||
|
||||
it('keeps the text when reopened', async () => {
|
||||
const c = await boot()
|
||||
const ta = await openNote(c)
|
||||
fireEvent.change(ta, { target: { value: 'still here' } })
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||
|
||||
const again = await openNote(c)
|
||||
expect(again.value).toBe('still here')
|
||||
})
|
||||
})
|
||||
40
test/search.test.ts
Normal file
40
test/search.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { simpleGit } from 'simple-git'
|
||||
import { listFiles, rgAvailable, searchContent } from '../src/main/search-service'
|
||||
|
||||
let dir = ''
|
||||
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
|
||||
|
||||
describe('search-service (real ripgrep via dynamic import)', () => {
|
||||
it('ripgrep is available (catches the ESM require() regression)', async () => {
|
||||
expect(await rgAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('finds content matches with line number + column', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
|
||||
await simpleGit(dir).init()
|
||||
await writeFile(join(dir, 'a.ts'), 'const x = 1\nconst balance = 2\n')
|
||||
await writeFile(join(dir, 'b.ts'), 'nothing relevant\n')
|
||||
const groups = await searchContent(dir, 'balance')
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0].path).toBe('a.ts')
|
||||
expect(groups[0].hits[0].no).toBe(2)
|
||||
expect(groups[0].hits[0].ln).toContain('balance')
|
||||
expect(groups[0].hits[0].ix).toBe('const '.length)
|
||||
})
|
||||
|
||||
it('returns [] for queries under 2 characters', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
|
||||
expect(await searchContent(dir, 'a')).toEqual([])
|
||||
})
|
||||
|
||||
it('lists project files', async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'helder-search-'))
|
||||
await simpleGit(dir).init()
|
||||
await writeFile(join(dir, 'keep.ts'), 'x')
|
||||
expect(await listFiles(dir)).toContain('keep.ts')
|
||||
})
|
||||
})
|
||||
105
test/titlebar.test.tsx
Normal file
105
test/titlebar.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The title bar: borderless actions that go accent when on, and the fullscreen
|
||||
// shift (macOS hides the traffic lights, so the project name moves to the edge).
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
vi.mock('../src/renderer/src/terminals', () => {
|
||||
let n = 0
|
||||
return { Terminal: () => null, lid: () => ++n }
|
||||
})
|
||||
|
||||
import { App } from '../src/renderer/src/App'
|
||||
import { ProjectProvider } from '../src/renderer/src/project'
|
||||
|
||||
/** Fires the fullscreen callbacks the App subscribed to. */
|
||||
let fullscreenCbs: ((on: boolean) => void)[] = []
|
||||
|
||||
function stubBridge(): void {
|
||||
const noop = (): void => {}
|
||||
const off = (): (() => void) => noop
|
||||
;(window as unknown as { helder: unknown }).helder = {
|
||||
platform: 'darwin',
|
||||
clipboard: { writeText: noop, readText: () => '' },
|
||||
project: {
|
||||
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||
recent: async () => [],
|
||||
},
|
||||
fs: {
|
||||
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||
create: async () => {}, mkdir: async () => {},
|
||||
},
|
||||
shell: { reveal: noop },
|
||||
notes: { read: async () => '', write: async () => {} },
|
||||
git: {
|
||||
load: async () => ({ branch: 'main', changes: [] }),
|
||||
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||
},
|
||||
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||
recent: { get: async () => [], set: async () => {} },
|
||||
search: { content: async () => [], files: async () => [] },
|
||||
dialog: { unsavedClose: async () => 'cancel' },
|
||||
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||
onFullscreen: (cb: (on: boolean) => void) => {
|
||||
fullscreenCbs.push(cb)
|
||||
return () => { fullscreenCbs = fullscreenCbs.filter((f) => f !== cb) }
|
||||
},
|
||||
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||
})
|
||||
beforeEach(() => { fullscreenCbs = []; stubBridge() })
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
delete (window as unknown as { helder?: unknown }).helder
|
||||
})
|
||||
|
||||
async function boot(): Promise<HTMLElement> {
|
||||
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||
return c
|
||||
}
|
||||
|
||||
function setFullscreen(on: boolean): void {
|
||||
act(() => { fullscreenCbs.forEach((cb) => cb(on)) })
|
||||
}
|
||||
|
||||
describe('title bar', () => {
|
||||
it('shifts left in fullscreen and back out again', async () => {
|
||||
const c = await boot()
|
||||
const bar = c.querySelector('.titlebar') as HTMLElement
|
||||
expect(bar.className).not.toContain('fullscreen')
|
||||
|
||||
await waitFor(() => expect(fullscreenCbs.length).toBe(1))
|
||||
setFullscreen(true)
|
||||
expect(bar.className).toContain('fullscreen')
|
||||
|
||||
setFullscreen(false)
|
||||
expect(bar.className).not.toContain('fullscreen')
|
||||
})
|
||||
|
||||
it('shows the state with the accent, not an On/Off badge', async () => {
|
||||
const c = await boot()
|
||||
const hidden = [...c.querySelectorAll<HTMLButtonElement>('.titlebar .tb-btn')]
|
||||
.find((b) => b.textContent?.includes('Hidden')) as HTMLButtonElement
|
||||
expect(c.querySelector('.titlebar .tb-state')).toBeNull()
|
||||
expect(hidden.className).not.toContain('on')
|
||||
|
||||
act(() => { hidden.click() })
|
||||
await waitFor(() => expect(hidden.className).toContain('on'))
|
||||
expect(hidden.textContent).not.toContain('On')
|
||||
})
|
||||
})
|
||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.{ts,tsx}'],
|
||||
// The renderer modules under test (diff, fuzzy, highlight) are DOM-free.
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user