update
This commit is contained in:
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
|
||||
|
||||
26
.helder/config.default.json
Normal file
26
.helder/config.default.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ai": {
|
||||
"command": "claude",
|
||||
"autoLaunch": true
|
||||
},
|
||||
"editor": {
|
||||
"autoSave": false,
|
||||
"tabSize": 4
|
||||
},
|
||||
"git": {
|
||||
"confirmDiscard": true,
|
||||
"confirmStage": false,
|
||||
"confirmUnstage": false,
|
||||
"defaultDiffMode": "diff"
|
||||
},
|
||||
"files": {
|
||||
"exclude": [],
|
||||
"followGitignore": true
|
||||
},
|
||||
"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
|
||||
}
|
||||
10
CLAUDE.md
10
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,6 +45,7 @@ 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.
|
||||
- **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.
|
||||
@@ -92,5 +93,8 @@ 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.
|
||||
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.
|
||||
|
||||
75
OVERNIGHT.md
Normal file
75
OVERNIGHT.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Overnight work log
|
||||
|
||||
Autonomous session continuing the Helder build while you slept. Everything kept
|
||||
compiling (`npm run build`), type-checking (`npm run typecheck`) and — new this
|
||||
session — passing tests (`npm run test`). Git left uncommitted for you to review.
|
||||
|
||||
## Plan
|
||||
1. Packaging (electron-builder)
|
||||
2. Automated test suite (vitest)
|
||||
3. Lint + format (eslint + prettier)
|
||||
4. Robustness + feature polish
|
||||
5. Developer README
|
||||
|
||||
## Progress
|
||||
|
||||
### 1. Packaging — done
|
||||
- Added `electron-builder.yml` (appId `com.blijnder.helder`, mac dmg+zip / win nsis / linux AppImage, unsigned local build).
|
||||
- Scripts: `npm run pack` (`--dir`), `npm run dist`, `npm run dist:mac`.
|
||||
- Made the bundled ripgrep path asar-safe (redirect to `app.asar.unpacked`).
|
||||
- `asarUnpack` for `node-pty` + `@vscode/ripgrep` so the native `.node` and `rg` binary load at runtime.
|
||||
- Verified: `npm run pack` produced `dist/mac-arm64/Helder.app` (245 MB) with `pty.node` + `rg` correctly unpacked. Unsigned (ad-hoc), default icon. `dist/` gitignored.
|
||||
|
||||
### 2. Automated tests — done (vitest)
|
||||
- Added vitest + `vitest.config.ts`; scripts `npm run test` / `test:watch`. **38 tests, all green.**
|
||||
- `test/diff.test.ts` — LCS diff: no-change, single change, new/deleted file, side marks, split alignment, trailing-newline.
|
||||
- `test/fuzzy.test.ts` — subsequence matcher (extracted to `src/renderer/src/fuzzy.ts`).
|
||||
- `test/highlight.test.ts` — ext/lang/label/icon/escape + Prism php highlighting (confirms markup-templating load order).
|
||||
- `test/config.test.ts` — `.helder` defaults regen, sparse deep-merge, theme.css not overwritten.
|
||||
- `test/fs.test.ts` — tree dirs-first + ignore dirs, content index skips binaries/node_modules, read/write round-trip.
|
||||
- `test/git.test.ts` — `classify` unit + real temp-repo integration (modified/untracked/staged, HEAD-vs-worktree text).
|
||||
|
||||
### 3. Lint + format — done
|
||||
- ESLint flat config (`eslint.config.js`): typescript-eslint recommended + react-hooks, prettier-disables, sensible ignores. `npm run lint` = **0 errors** (8 intentional exhaustive-deps warnings).
|
||||
- Fixed real `no-unused-expressions` violations (short-circuit/ternary-as-statement).
|
||||
- `.prettierrc.json` added (style: no-semi, single-quote, width 140). Not auto-applied to avoid churn.
|
||||
|
||||
### 4. Robustness + feature polish — done
|
||||
- **Fixed a real `discard` bug**: `git checkout` can't remove a new/untracked file. `discard` now reverts modified→HEAD, restores deleted, and removes new/untracked (staged or not). Covered by new git tests.
|
||||
- **Close-tab guard**: confirms before closing a tab with unsaved edits (× / middle-click / ⌘W), and clears its buffer on close. Reads fresh state via refs so the ⌘W path is correct too.
|
||||
- **ErrorBoundary** around the whole app — a render fault shows a dark, recoverable panel (with the message + Reload) instead of a blank window.
|
||||
- **`editor.tabSize` wired** into the editable buffer (textarea + highlighted pre) and the status bar — config now visibly does something.
|
||||
- **Layout persistence**: the three column widths + the terminal split fraction persist across launches via localStorage (`persist.ts`), making the README's "positions persist across launches" claim true.
|
||||
|
||||
### 5. Developer docs + icon — done
|
||||
- `README.md` updated from "greenfield" to the real working build: getting-started, scripts table, repository layout.
|
||||
- App icon generated from Helder's spark mark → `build/icon.png` (1024²); electron-builder embeds `icon.icns` in the packaged app (verified).
|
||||
|
||||
### 6. Renderer component tests + CI — done
|
||||
- Added jsdom + `@testing-library/react`; the terminals (xterm) are mocked so tests stay deterministic.
|
||||
- `test/app.test.tsx` — workbench renders, open changed file → diff tab, edit → dirty tab, content search returns hits.
|
||||
- `test/app-interactions.test.tsx` — **Pass-on-to-Agent** emits exactly `"<note> <path:line>"` via the `agentPaste` event; **stage → commit** toasts.
|
||||
- `test/editor-modes.test.tsx` — Updated mode is editable / Original is read-only; Split opens the two-pane overlay and Esc collapses it.
|
||||
- **49 tests total, all green.**
|
||||
- `.github/workflows/ci.yml` — runs typecheck · lint · test · build on push/PR.
|
||||
|
||||
### 7. Finish to the DESIGN.md spec — done
|
||||
Audited `DESIGN.md` section by section and closed every remaining behavioral gap:
|
||||
- **Session restore** (`session.restoreOnLaunch`, §9) — open tabs, active tab, and per-tab view modes are restored per project (persisted in localStorage, keyed by root); splitter layout already persisted.
|
||||
- **Close-dirty tab** (§5) — now a real **Save / Don't Save / Cancel** native dialog (was discard-or-cancel). `⌘S` save unchanged.
|
||||
- **Unsaved indicator** (§5) — a **dot in place of the close control** (× returns on hover), matching the spec exactly (was a dot after the name).
|
||||
- **New config keys, all wired**: `git.confirmStage` / `git.confirmUnstage` (default off, prompt when on), `git.defaultDiffMode` (drives the starting mode), `files.exclude` + `files.followGitignore`, `session.restoreOnLaunch`.
|
||||
- **Unified file source**: `rg --files` is now the single source for the **Explorer tree + content index + search**, so **gitignore and `files.exclude` are honored consistently everywhere** (fs-walk fallback when ripgrep is absent). Dotfiles (`.env`, `.gitignore`) included via `--hidden`.
|
||||
- **⌘P** aliases the unified search — no separate Go-to-File overlay, per the resolved decision (CLAUDE.md/README).
|
||||
- Tests now **51** (added `buildTreeFromPaths`, gitignore-in-repo, updated dirty-tab class).
|
||||
|
||||
### 8. Live-run bugfix (found by running `npm run dev`)
|
||||
- **Symptom:** the real Electron window showed mock "console" data and both terminals said "not available in browser preview" — i.e. `window.helder` was undefined in the actual app, so the renderer fell back to mock mode.
|
||||
- **Cause:** electron-vite built the preload as `out/preload/index.mjs`, but `main/index.ts` loaded `../preload/index.js` (wrong extension) → Electron silently loaded no preload → no bridge.
|
||||
- **Fix:** build the preload as **CommonJS `index.cjs`** (loads synchronously before the page, so `contextBridge` is exposed by the time React mounts) and point `main` at `../preload/index.cjs`. Verified the built `index.cjs` exposes `helder` and main references it. **Re-run `npm run dev` to confirm the live window now loads the real project + terminals.**
|
||||
|
||||
## Final state (all green)
|
||||
- `npm run typecheck` ✓ · `npm run lint` ✓ (0 errors, 8 intentional warnings) · `npm test` ✓ (51) · `npm run build` ✓ · `npm run pack` ✓ (`Helder.app` with icon + unpacked native binaries)
|
||||
- **DESIGN.md is now fully implemented** (the only intentional exception is the separate Go-to-File overlay, replaced by ⌘P→unified-search per the resolved decision).
|
||||
- Everything left **uncommitted** for your review (laptop is read-only on git).
|
||||
- The only thing not exercisable in this headless session remains the live Electron GUI — `npm run dev` to see it.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
26
electron-builder.yml
Normal file
26
electron-builder.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
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.
|
||||
asarUnpack:
|
||||
- '**/node_modules/node-pty/**'
|
||||
- '**/node_modules/@vscode/ripgrep/**'
|
||||
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}
|
||||
win:
|
||||
target: nsis
|
||||
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',
|
||||
|
||||
40
eslint.config.js
Normal file
40
eslint.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
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 } },
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
)
|
||||
5441
package-lock.json
generated
5441
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
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": {
|
||||
@@ -27,15 +33,27 @@
|
||||
},
|
||||
"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 }
|
||||
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' },
|
||||
files: { exclude: [], followGitignore: true },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in dark theme.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { listFiles } 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,62 @@ 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). */
|
||||
export function buildTreeFromPaths(rootName: string, paths: string[]): FileNode {
|
||||
const root: FileNode = { name: rootName, type: 'dir', path: '', open: true, children: [] }
|
||||
const dirs = new Map<string, FileNode>([['', root]])
|
||||
for (const rel of paths) {
|
||||
const parts = rel.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
let parent = root
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const isFile = i === parts.length - 1
|
||||
const curPath = parentPath ? `${parentPath}/${parts[i]}` : parts[i]
|
||||
if (isFile) {
|
||||
parent.children!.push({ name: parts[i], type: 'file', path: curPath })
|
||||
} else {
|
||||
let dir = dirs.get(curPath)
|
||||
if (!dir) {
|
||||
dir = { name: parts[i], type: 'dir', path: curPath, open: parts.slice(0, i + 1).length <= 1, children: [] }
|
||||
dirs.set(curPath, dir)
|
||||
parent.children!.push(dir)
|
||||
}
|
||||
parent = dir
|
||||
parentPath = curPath
|
||||
}
|
||||
}
|
||||
}
|
||||
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). Fallback:
|
||||
* a plain recursive walk (when ripgrep is unavailable). */
|
||||
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 = await listFiles(root).catch(() => [] as string[])
|
||||
if (paths.length) return buildTreeFromPaths(rootName(root), paths)
|
||||
return { name: rootName(root), type: 'dir', path: '', open: true, children: await readDir(root, root, 0) }
|
||||
}
|
||||
|
||||
async function readDir(abs: string, root: string, depth: number): Promise<FileNode[]> {
|
||||
@@ -55,12 +108,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))
|
||||
@@ -74,14 +121,38 @@ export async function writeProjectFile(root: string, rel: string, content: strin
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 +173,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 +181,6 @@ export async function readAll(root: string): Promise<Record<string, string>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { simpleGit, type SimpleGit } from 'simple-git'
|
||||
|
||||
@@ -22,7 +22,7 @@ function git(root: string): SimpleGit {
|
||||
}
|
||||
|
||||
/** Map a porcelain code pair to our display letter + staged flag. */
|
||||
function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } {
|
||||
const staged = index !== ' ' && index !== '?'
|
||||
const code = staged ? index : working
|
||||
let letter: GitStatusLetter
|
||||
@@ -103,6 +103,20 @@ export async function commit(root: string, message: string): Promise<void> {
|
||||
await git(root).commit(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
await git(root).checkout(['--', ...paths])
|
||||
const g = git(root)
|
||||
for (const p of paths) {
|
||||
const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false)
|
||||
if (inHead) {
|
||||
await g.checkout(['HEAD', '--', p])
|
||||
} else {
|
||||
try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ }
|
||||
await rm(join(root, p), { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, dialog, shell, BrowserWindow, ipcMain } from 'electron'
|
||||
import { watch, type FSWatcher } from 'chokidar'
|
||||
import { getName, getRoot, openDialog } from './project'
|
||||
import { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
||||
@@ -84,6 +84,20 @@ function registerIpc(): void {
|
||||
|
||||
ipcMain.handle('search:content', (_e, query: string) => searchContent(getRoot(), query))
|
||||
ipcMain.handle('search:files', () => listFiles(getRoot()))
|
||||
|
||||
ipcMain.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'
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
@@ -97,7 +111,7 @@ function createWindow(): void {
|
||||
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,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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. */
|
||||
/** 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. */
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
let rgPath: string | null = null
|
||||
try {
|
||||
rgPath = (require('@vscode/ripgrep') 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 (rgPath) rgPath = rgPath.replace(/\bapp\.asar\b/, 'app.asar.unpacked')
|
||||
} catch (e) {
|
||||
console.error('[helder] @vscode/ripgrep unavailable:', (e as Error).message)
|
||||
}
|
||||
@@ -16,12 +22,29 @@ try {
|
||||
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 function rgAvailable(): boolean {
|
||||
return !!rgPath
|
||||
}
|
||||
|
||||
/** 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('/')
|
||||
}
|
||||
@@ -30,9 +53,9 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
|
||||
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 +88,12 @@ export function searchContent(root: string, query: string): Promise<ContentGroup
|
||||
})
|
||||
}
|
||||
|
||||
/** All project files (relative paths), honoring gitignore + excludes. Includes
|
||||
* dotfiles (--hidden) so .env etc. show up unless ignored. */
|
||||
export function listFiles(root: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (!rgPath) return resolve([])
|
||||
const child = spawn(rgPath, ['--files', ...IGNORE_GLOBS, '--', root])
|
||||
const child = spawn(rgPath, ['--files', '--hidden', ...ignoreArgs(), '--', root])
|
||||
let buf = ''
|
||||
const out: string[] = []
|
||||
let done = false
|
||||
|
||||
@@ -61,6 +61,11 @@ const api = {
|
||||
files: (): Promise<string[]> => ipcRenderer.invoke('search:files'),
|
||||
},
|
||||
|
||||
dialog: {
|
||||
unsavedClose: (path: string): Promise<'save' | 'discard' | 'cancel'> =>
|
||||
ipcRenderer.invoke('dialog:unsavedClose', path),
|
||||
},
|
||||
|
||||
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
|
||||
onProjectChanged: (cb: () => void): (() => void) => {
|
||||
const handler = (): void => cb()
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContextMenu, PassPopup, SearchModal, Toasts } from './overlays'
|
||||
import type { Menu, Toast } from './overlays'
|
||||
import type { FileNode, GitStatus } from './types'
|
||||
import { useProject, useProjectActions } from './project'
|
||||
import { loadJson, loadNum, saveJson, saveNum } from './persist'
|
||||
|
||||
const NO_COMMITTED = new Set<string>()
|
||||
|
||||
@@ -36,8 +37,9 @@ function Splitter({ orientation = 'v', onDelta }: { orientation?: 'v' | 'h'; onD
|
||||
}
|
||||
|
||||
function RightColumn({ width }: { width: number }): React.ReactElement {
|
||||
const [topFrac, setTopFrac] = useState(0.52)
|
||||
const [topFrac, setTopFrac] = useState(() => loadNum('helder.topFrac', 0.52))
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => saveNum('helder.topFrac', topFrac), [topFrac])
|
||||
function delta(_dx: number, dy: number): void {
|
||||
const h = ref.current ? ref.current.clientHeight : 600
|
||||
setTopFrac((f) => Math.max(0.18, Math.min(0.82, (f * h + dy) / h)))
|
||||
@@ -94,6 +96,7 @@ export function App(): React.ReactElement {
|
||||
// Editable buffers: path → current text (absent = clean, showing on-disk content).
|
||||
const [buffers, setBuffers] = useState<Record<string, string>>({})
|
||||
const buffersRef = useRef(buffers); buffersRef.current = buffers
|
||||
const projRef = useRef(proj); projRef.current = proj
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
function diskText(path: string): string { return proj.files[path] ?? '' }
|
||||
@@ -127,9 +130,12 @@ export function App(): React.ReactElement {
|
||||
toast('Discarded changes', path)
|
||||
}
|
||||
|
||||
const [gitW, setGitW] = useState(232)
|
||||
const [treeW, setTreeW] = useState(244)
|
||||
const [rightW, setRightW] = useState(444)
|
||||
const [gitW, setGitW] = useState(() => loadNum('helder.gitW', 232))
|
||||
const [treeW, setTreeW] = useState(() => loadNum('helder.treeW', 244))
|
||||
const [rightW, setRightW] = useState(() => loadNum('helder.rightW', 444))
|
||||
useEffect(() => saveNum('helder.gitW', gitW), [gitW])
|
||||
useEffect(() => saveNum('helder.treeW', treeW), [treeW])
|
||||
useEffect(() => saveNum('helder.rightW', rightW), [rightW])
|
||||
|
||||
// Seed explorer expansion from the tree's `open` flags once per opened project.
|
||||
const seededRoot = useRef<string | null | undefined>(undefined)
|
||||
@@ -140,6 +146,26 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
}, [proj.tree, proj.root])
|
||||
|
||||
// Session restore (session.restoreOnLaunch): bring back the open tabs, active
|
||||
// tab and per-tab view modes for this project, then keep them persisted.
|
||||
const sessionRoot = useRef<string | null | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!proj.ready || sessionRoot.current === proj.root) return
|
||||
sessionRoot.current = proj.root
|
||||
if (!proj.config.session.restoreOnLaunch) return
|
||||
const saved = loadJson<{ tabs: string[]; active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null)
|
||||
if (saved && Array.isArray(saved.tabs)) {
|
||||
setTabs(saved.tabs.map((p) => ({ path: p })))
|
||||
setActive(saved.active ?? null)
|
||||
setTabMode(saved.tabMode ?? {})
|
||||
}
|
||||
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
|
||||
saveJson(`helder.session:${proj.root}`, { tabs: tabs.map((t) => t.path), active, tabMode })
|
||||
}, [tabs, active, tabMode, proj.root, proj.config.session.restoreOnLaunch])
|
||||
|
||||
function toast(title: string, ref?: string): void {
|
||||
const id = lid()
|
||||
setToasts((t) => [...t, { id, title, ref }])
|
||||
@@ -158,7 +184,7 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
|
||||
const toggleDir = useCallback((p: string) => {
|
||||
setOpenDirs((s) => { const n = new Set(s); n.has(p) ? n.delete(p) : n.add(p); return n })
|
||||
setOpenDirs((s) => { const n = new Set(s); if (n.has(p)) n.delete(p); else n.add(p); return n })
|
||||
}, [])
|
||||
|
||||
function reveal(path: string): void {
|
||||
@@ -174,12 +200,22 @@ export function App(): React.ReactElement {
|
||||
setCommitMsg('')
|
||||
}
|
||||
|
||||
const defaultMode: Mode = proj.config.git.defaultDiffMode
|
||||
function stageGuarded(p: string): void {
|
||||
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
|
||||
actions.stage(p)
|
||||
}
|
||||
function unstageGuarded(p: string): void {
|
||||
if (proj.config.git.confirmUnstage && !window.confirm(`Unstage ${p}?`)) return
|
||||
actions.unstage(p)
|
||||
}
|
||||
|
||||
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void {
|
||||
const changed = !!proj.diffs[path]
|
||||
actions.ensureFile(path)
|
||||
setTabs((t) => t.some((x) => x.path === path) ? t : [...t, { path }])
|
||||
setActive(path)
|
||||
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? 'diff' : (m[path] || (changed ? 'diff' : 'code')) }))
|
||||
setTabMode((m) => ({ ...m, [path]: opts.diff && changed ? defaultMode : (m[path] || (changed ? defaultMode : 'code')) }))
|
||||
reveal(path)
|
||||
if (opts.line) {
|
||||
// show the current/updated file so line numbers map to search hits
|
||||
@@ -194,7 +230,8 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(path: string): void {
|
||||
function removeTab(path: string): void {
|
||||
setBuffers((b) => { if (b[path] == null) return b; const n = { ...b }; delete n[path]; return n })
|
||||
setTabs((t) => {
|
||||
const ix = t.findIndex((x) => x.path === path)
|
||||
const next = t.filter((x) => x.path !== path)
|
||||
@@ -206,6 +243,20 @@ export function App(): React.ReactElement {
|
||||
})
|
||||
}
|
||||
|
||||
async function closeTab(path: string): Promise<void> {
|
||||
const buf = buffersRef.current[path]
|
||||
const dirtyNow = buf != null && buf !== (projRef.current.files[path] ?? '')
|
||||
if (dirtyNow) {
|
||||
const bridge = window.helder
|
||||
const choice = bridge
|
||||
? await bridge.dialog.unsavedClose(path)
|
||||
: (window.confirm(`Discard unsaved changes to ${path}?`) ? 'discard' : 'cancel')
|
||||
if (choice === 'cancel') return
|
||||
if (choice === 'save') writeToDisk(path, buf as string)
|
||||
}
|
||||
removeTab(path)
|
||||
}
|
||||
|
||||
// ---- context menus ----
|
||||
function openMenu(e: React.MouseEvent, target: ContextTarget): void {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
@@ -234,8 +285,8 @@ export function App(): React.ReactElement {
|
||||
if (target.kind === 'git') {
|
||||
const isStaged = proj.staged.has(target.path)
|
||||
items.push(isStaged
|
||||
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => actions.unstage(target.path) }
|
||||
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => actions.stage(target.path) })
|
||||
? { icon: Icon.minus(), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
|
||||
: { icon: Icon.plus(), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
|
||||
items.push({ icon: Icon.diff(), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
|
||||
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
|
||||
}
|
||||
@@ -250,7 +301,8 @@ export function App(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
const meta = e.metaKey || e.ctrlKey
|
||||
if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setOverlay('search') }
|
||||
// ⌘F and ⌘P both open the unified search (it covers file names too).
|
||||
if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setOverlay('search') }
|
||||
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
|
||||
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
||||
else if (e.key === 'Escape') { if (splitFor) setSplitFor(null); else { setOverlay(null); setMenu(null) } }
|
||||
@@ -263,10 +315,10 @@ export function App(): React.ReactElement {
|
||||
const MODE_WORD: Record<string, string> = { original: 'Original', updated: 'Updated', diff: 'Diff' }
|
||||
const resolvedTabs = tabs.map((t) => {
|
||||
const changed = !!proj.diffs[t.path]
|
||||
const m = tabMode[t.path] || (changed ? 'diff' : 'code')
|
||||
const m = tabMode[t.path] || (changed ? defaultMode : 'code')
|
||||
return { ...t, changed, modeLabel: splitFor === t.path ? 'split' : MODE_LABEL[m], dirty: isDirty(t.path) }
|
||||
})
|
||||
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? 'diff' : 'code')
|
||||
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
|
||||
const activeLang = active ? HL.langLabel(active) : ''
|
||||
|
||||
@@ -298,7 +350,7 @@ export function App(): React.ReactElement {
|
||||
<div className="col" style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}>
|
||||
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
||||
onStage={actions.stage} onUnstage={actions.unstage} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
|
||||
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
|
||||
onOpen={openFile} onContext={openMenu} activePath={active} />
|
||||
</div>
|
||||
<Splitter onDelta={(dx) => setGitW((w) => clamp(w + dx, 160, 460))} />
|
||||
@@ -332,7 +384,7 @@ export function App(): React.ReactElement {
|
||||
<div className="sb"><span className="a">+{totals.add}</span> <span className="d">−{totals.del}</span></div>
|
||||
<div className="sb spacer" />
|
||||
{active && <div className="sb">{selection && selection.path === active && selection.start !== selection.end ? `${selection.end - selection.start + 1} lines selected` : `Ln ${curLine}, Col ${curCol}`}</div>}
|
||||
{active && <div className="sb">Spaces: 4</div>}
|
||||
{active && <div className="sb">Spaces: {proj.config.editor.tabSize}</div>}
|
||||
{active && <div className="sb">UTF-8</div>}
|
||||
{active && <div className="sb"><b>{activeLang}</b></div>}
|
||||
{active && proj.diffs[active] && <div className="sb">{splitFor === active ? 'Split' : (MODE_WORD[mode] || '')}</div>}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -68,12 +69,12 @@ 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) }}
|
||||
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>
|
||||
@@ -97,7 +98,7 @@ function EditorTabs({ tabs, active, onActivate, onClose }: {
|
||||
const name = t.path.split('/').pop()
|
||||
return (
|
||||
<div key={t.path}
|
||||
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirty' : '')}
|
||||
className={'tab' + (active === t.path ? ' active' : '') + (t.dirty ? ' dirtyclose' : '')}
|
||||
onClick={() => onActivate(t.path)}
|
||||
onAuxClick={(e) => { if (e.button === 1) { e.preventDefault(); onClose(t.path) } }}
|
||||
title={t.path}>
|
||||
@@ -107,6 +108,7 @@ function EditorTabs({ tabs, active, onActivate, onClose }: {
|
||||
<span className="tclose" onClick={(e) => { e.stopPropagation(); onClose(t.path) }}>
|
||||
{Icon.close()}
|
||||
</span>
|
||||
{t.dirty && <span className="tdot" title="Unsaved changes" />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
3
src/renderer/src/env.d.ts
vendored
3
src/renderer/src/env.d.ts
vendored
@@ -46,6 +46,9 @@ interface HelderBridge {
|
||||
content: (query: string) => Promise<{ path: string; hits: { no: number; ln: string; ix: number }[] }[]>
|
||||
files: () => Promise<string[]>
|
||||
}
|
||||
dialog: {
|
||||
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
|
||||
}
|
||||
onProjectChanged: (cb: () => void) => () => void
|
||||
onConfigChanged: (cb: () => void) => () => void
|
||||
}
|
||||
|
||||
41
src/renderer/src/error-boundary.tsx
Normal file
41
src/renderer/src/error-boundary.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
interface State {
|
||||
error: Error | 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 }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||
console.error('[helder] render error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const { error } = 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.message}</pre>
|
||||
<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>
|
||||
</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
|
||||
}
|
||||
@@ -13,11 +13,14 @@ import './styles.css'
|
||||
|
||||
import { App } from './App'
|
||||
import { ProjectProvider } from './project'
|
||||
import { ErrorBoundary } from './error-boundary'
|
||||
|
||||
createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>
|
||||
<ErrorBoundary>
|
||||
<ProjectProvider>
|
||||
<App />
|
||||
</ProjectProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* 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 { fuzzy } from './fuzzy'
|
||||
import { FileIcon, Icon } from './components'
|
||||
import type { OpenFile } from './components'
|
||||
|
||||
@@ -17,15 +18,6 @@ export interface Toast { id: number; title: string; ref?: string }
|
||||
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)
|
||||
@@ -48,7 +40,7 @@ export function SearchModal({ onOpen, onOpenAt, onClose, changeSet }: {
|
||||
// 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()
|
||||
if (bridge) bridge.search.files().then((f) => setAllPaths(f.length ? f : Object.keys(PROJECT.files))).catch(() => setAllPaths(Object.keys(PROJECT.files)))
|
||||
}, [])
|
||||
|
||||
@@ -205,7 +197,7 @@ export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: ()
|
||||
{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() }}>
|
||||
onClick={() => { it.onClick?.(); onClose() }}>
|
||||
<span className="ic">{it.icon}</span>
|
||||
<span>{it.label}</span>
|
||||
{it.kbd && <span className="kc">{it.kbd}</span>}
|
||||
@@ -239,7 +231,7 @@ export function PassPopup({ x, y, refStr, onConfirm, onCancel }: {
|
||||
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() } }
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
@@ -65,17 +65,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 }
|
||||
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' },
|
||||
files: { exclude: [], followGitignore: true },
|
||||
terminal: { shell: null },
|
||||
session: { restoreOnLaunch: true },
|
||||
}
|
||||
|
||||
89
test/app-interactions.test.tsx
Normal file
89
test/app-interactions.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @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))
|
||||
}
|
||||
|
||||
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()
|
||||
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 to Agent')
|
||||
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, commits with a message, and toasts', 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
|
||||
})
|
||||
const stageBtn = row.querySelector<HTMLButtonElement>('button[title="Stage changes"]')!
|
||||
fireEvent.click(stageBtn)
|
||||
|
||||
// commit button reflects the staged count once a file is staged
|
||||
await waitFor(() => expect(find(c, '.commit-btn', 'Commit')?.textContent).toMatch(/Commit\s*\d/))
|
||||
|
||||
const msg = c.querySelector<HTMLTextAreaElement>('.commit-input')!
|
||||
fireEvent.change(msg, { target: { value: 'wire up balance' } })
|
||||
const commitBtn = find(c, '.commit-btn', 'Commit') as HTMLButtonElement
|
||||
expect(commitBtn.disabled).toBe(false)
|
||||
fireEvent.click(commitBtn)
|
||||
|
||||
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
|
||||
})
|
||||
})
|
||||
93
test/app.test.tsx
Normal file
93
test/app.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// @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))
|
||||
}
|
||||
|
||||
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.textContent).toContain('Source Control')
|
||||
expect(c.textContent).toContain('Explorer')
|
||||
// no file open yet
|
||||
expect(c.textContent).toContain('No file open')
|
||||
})
|
||||
|
||||
it('opens a changed file from the git panel into a diff tab', 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)
|
||||
await waitFor(() => expect(rowWithText(c, '.tab', 'UserController.php')).toBeTruthy())
|
||||
// changed file → diff toolbar with a status word
|
||||
expect(c.querySelector('.diff-bar')?.textContent).toContain('Modified')
|
||||
})
|
||||
|
||||
it('makes an edited buffer dirty (tab dot)', async () => {
|
||||
const c = renderApp()
|
||||
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('.tab.dirtyclose')).toBeNull()
|
||||
fireEvent.change(ta, { target: { value: '// edited\n' } })
|
||||
await waitFor(() => expect(c.querySelector('.tab.dirtyclose')).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 content/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('Updated mode is an editable buffer; Original is read-only', async () => {
|
||||
const c = await openChanged()
|
||||
fireEvent.click(find(c, '.seg button', 'Updated')!)
|
||||
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())
|
||||
})
|
||||
})
|
||||
78
test/fs.test.ts
Normal file
78
test/fs.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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, readAll, 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'))
|
||||
})
|
||||
})
|
||||
|
||||
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('honors .gitignore in a repo (files.followGitignore default on)', 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).not.toContain('secret.txt')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
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([])
|
||||
})
|
||||
})
|
||||
105
test/git.test.ts
Normal file
105
test/git.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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('maps unmerged (U) to modified', () => {
|
||||
expect(classify('U', 'U').letter).toBe('M')
|
||||
})
|
||||
})
|
||||
|
||||
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('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>')
|
||||
})
|
||||
})
|
||||
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