Compare commits

...

8 Commits

Author SHA1 Message Date
e126182ae6 improvements
Some checks failed
CI / check (push) Has been cancelled
2026-07-31 13:29:22 +02:00
daf8945da7 improvements 2026-07-29 14:17:56 +02:00
03e16d49a1 handling files when stages and dirty at once 2026-07-28 08:57:36 +02:00
d3bcdb74c2 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-29 09:00:51 +02:00
6c3a021bb9 update
Some checks failed
CI / check (push) Has been cancelled
2026-06-24 13:59:58 +02:00
43131915c0 faster loading
Some checks failed
CI / check (push) Has been cancelled
2026-06-23 08:52:05 +02:00
73bfd2b86a improvements
Some checks failed
CI / check (push) Has been cancelled
2026-06-22 10:18:18 +02:00
ab6f09bde2 several design improvements 2026-06-22 09:27:39 +02:00
38 changed files with 2520 additions and 253 deletions

View File

@@ -11,7 +11,8 @@
"confirmDiscard": true, "confirmDiscard": true,
"confirmStage": false, "confirmStage": false,
"confirmUnstage": false, "confirmUnstage": false,
"defaultDiffMode": "diff" "defaultDiffMode": "diff",
"refreshInterval": 10000
}, },
"files": { "files": {
"exclude": [], "exclude": [],

View File

@@ -51,6 +51,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
- **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes. - **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes.
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery. - **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal. - **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts``rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask) ## Confirmed decisions (the "Open assumptions" in DESIGN.md are resolved — do not re-ask)
@@ -74,6 +75,17 @@ Settings are project-scoped, living in a `.helder/` folder in the opened project
- Effective value = `config.json` if present, else `config.default.json`, merged key by key. - Effective value = `config.json` if present, else `config.default.json`, merged key by key.
- `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files. - `.helder/theme.css` — custom CSS theme applied over the built-in dark theme; **code font and font size live here**, not in the config files.
## Logging & crash diagnostics
One file, `~/Library/Logs/Helder/helder.log` (rotates at 2 MB, keeps 3), written **synchronously** so a line survives the process dying right after it. Reachable from **Help → Open Log** and from the crash panel's *Open Log* button. Main and renderer both write to it, so a failure reads as one chronological story.
- `src/main/logger.ts` — the sink. Deliberately imports NO electron so it stays unit-testable (`test/logger.test.ts`); `initLogger({dir})` is handed the path by the caller. Every process logs its pid, since sibling project windows share the file.
- `src/main/diagnostics.ts``initDiagnostics()` runs **before** `app.whenReady()` (crashReporter must start early; `app.setName` must precede `app.getPath('logs')` or logs land in `~/Library/Logs/Electron`). Hooks `uncaughtException`, `unhandledRejection`, `render-process-gone` (the blank-window crash), `child-process-gone`, `preload-error`, `unresponsive`, and renderer console warnings/errors. Native minidumps (node-pty can segfault) go to `app.getPath('crashDumps')`, local only — nothing is uploaded.
- `src/main/index.ts` — the `handle()` / `on()` wrappers around `ipcMain`: every IPC failure is logged with channel + args, then **rethrown** so renderer behaviour is unchanged. Calls over 1 s log a `slow` warning. Note both wrappers must call `ipcMain.handle`/`ipcMain.on` — a rename that rewrites those lines makes the wrappers infinitely recursive and silently registers **no handlers at all** (every IPC then fails with "No handler registered").
- `src/renderer/src/log.ts``rlog` + `installErrorLogging()` (window `error`, `unhandledrejection`). Called from `main.tsx` before first render. `ErrorBoundary` logs the component stack, which exists nowhere else.
Keep warnings honest: an expected event must not log as WARN (see `killing` in `pty-service.ts` — deliberate kills log INFO). A log full of false alarms is a log nobody reads.
## Design tokens ## Design tokens
Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a``--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle. Canonical source is the `:root` block in `design_handoff_helder_workbench/design/styles.css`. Surfaces are cool charcoal (`--bg-0` editor `#16171a``--bg-3` headers/tabs `#23262b`); single cool-blue accent `--accent #4d8dff`; git status `--add #5cbd6b` / `--del #e0696a` / `--mod #d8a85c` / `--ren #5aa6d6`. File-type icons are 15×15 monogram chips (no brand logos). Recreate UI icons as a small inline-SVG set (or Lucide), keeping the monogram chips for file types. Respect `prefers-reduced-motion`; keep motion subtle.
@@ -98,4 +110,6 @@ Canonical source is the `:root` block in `design_handoff_helder_workbench/design
- `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied). - `npm run lint` — ESLint (flat config in `eslint.config.js`). `.prettierrc.json` defines formatting (not auto-applied).
- `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged. - `npm run pack` — unpacked app into `dist/` (electron-builder, unsigned). `npm run dist` / `dist:mac` for distributables. App icon comes from `build/icon.png`. Native `node-pty` + `rg` are asar-unpacked so they load when packaged.
**The mac build must be ad-hoc signed — `build/adhoc-sign.cjs` (the `afterPack` hook) does this.** `mac.identity: null` skips signing, which leaves the .app carrying only the linker signature Apple put on the prebuilt Electron binary: it reports `Identifier=Electron`, seals no resources, and does not bind our Info.plist. macOS reads that as a tampered bundle and kills it with *"Malware Blocked and Moved to Trash"*. A real ad-hoc signature over the whole bundle (with `build/entitlements.mac.plist` for JIT + library validation) fixes it. Still not notarized, so a copy opened from the DMG carries a quarantine flag — clear it with `xattr -dr com.apple.quarantine /Applications/Helder.app` or ship a Developer ID build.
Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code. Keep all five green (typecheck · lint · test · build, and pack when touching main/packaging) when changing code.

44
build/adhoc-sign.cjs Normal file
View File

@@ -0,0 +1,44 @@
// Ad-hoc sign the macOS bundle after electron-builder packs it.
//
// Why this exists: `mac.identity: null` tells electron-builder to skip signing
// altogether. The .app then keeps only the linker signature that Apple put on
// the prebuilt Electron binary. That signature says `Identifier=Electron`,
// seals no resources, and does not bind our Info.plist. macOS reads a bundle
// like that as tampered-with and shows "Malware Blocked and Moved to Trash".
//
// A real ad-hoc signature over the whole bundle fixes it. The app stays
// unsigned in the Developer ID sense (no notarization, so a *downloaded* copy
// still needs the quarantine flag cleared), but it is no longer flagged as
// malware and runs fine locally.
//
// Replace this with a Developer ID identity + notarization when the app ships.
const { execFileSync } = require('node:child_process')
const path = require('node:path')
exports.default = async function adhocSign(context) {
if (context.electronPlatformName !== 'darwin') return
const appName = context.packager.appInfo.productFilename
const appPath = path.join(context.appOutDir, `${appName}.app`)
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
console.log(` • ad-hoc signing ${appPath}`)
execFileSync(
'codesign',
[
'--force',
'--deep',
'--sign',
'-',
'--options',
'runtime',
'--entitlements',
entitlements,
appPath
],
{ stdio: 'inherit' }
)
// Fail the build rather than ship a bundle macOS will quarantine again.
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' })
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- V8 compiles JavaScript at runtime. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Electron sets dyld vars when it spawns its own helpers. -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<!-- node-pty is a native addon signed with a different (ad-hoc) identity. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

View File

@@ -14,6 +14,9 @@ asarUnpack:
- '**/node_modules/node-pty/**' - '**/node_modules/node-pty/**'
- '**/node_modules/@vscode/ripgrep/**' - '**/node_modules/@vscode/ripgrep/**'
- '**/node_modules/@vscode/ripgrep-*/**' - '**/node_modules/@vscode/ripgrep-*/**'
# Ad-hoc signs the .app on macOS. Without it the bundle keeps only Electron's
# linker signature, seals no resources, and macOS blocks it as malware.
afterPack: build/adhoc-sign.cjs
mac: mac:
category: public.app-category.developer-tools category: public.app-category.developer-tools
target: target:
@@ -22,8 +25,20 @@ mac:
# Local/unsigned build: ad-hoc signed by electron-builder, no notarization. # Local/unsigned build: ad-hoc signed by electron-builder, no notarization.
identity: null identity: null
artifactName: ${productName}-${version}-${arch}.${ext} artifactName: ${productName}-${version}-${arch}.${ext}
files:
# Keep the cross-build leftovers (see win:) out of the mac package.
- '!**/node_modules/@vscode/ripgrep-win32-*/**'
win: win:
target: nsis target: nsis
# Cross-built from macOS with `-c.npmRebuild=false`: node-pty can't be
# cross-compiled, but it ships prebuilds/win32-* and its loader falls back
# to those when build/Release is absent. The win32 rg binary is provided by
# manually extracting @vscode/ripgrep-win32-x64 into node_modules (npm
# refuses to install it on darwin).
files:
- '!**/node_modules/node-pty/build/**'
- '!**/node_modules/@vscode/ripgrep-darwin-*/**'
- '!**/node_modules/@vscode/ripgrep-linux-*/**'
linux: linux:
target: AppImage target: AppImage
category: Development category: Development

View File

@@ -29,6 +29,12 @@ export default tseslint.config(
files: ['test/**/*.{ts,tsx}'], files: ['test/**/*.{ts,tsx}'],
languageOptions: { globals: { ...globals.node, ...globals.browser } }, languageOptions: { globals: { ...globals.node, ...globals.browser } },
}, },
{
// electron-builder hooks: plain CommonJS, run by node outside the app.
files: ['build/**/*.cjs'],
languageOptions: { globals: { ...globals.node }, sourceType: 'commonjs' },
rules: { '@typescript-eslint/no-require-imports': 'off' },
},
{ {
rules: { rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],

Binary file not shown.

View File

@@ -15,7 +15,7 @@ export type DiffMode = 'original' | 'updated' | 'diff'
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number } editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean } session: { restoreOnLaunch: boolean }
@@ -24,7 +24,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = { export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false }, files: { exclude: [], followGitignore: false },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true }, session: { restoreOnLaunch: true },

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

@@ -0,0 +1,188 @@
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
/**
* Everything that turns a silent death into a log line. Wires the process-,
* app- and window-level failure hooks Electron gives us, none of which were
* connected before — which is why crashes left no trace.
*
* The hooks, and the crash each one actually catches:
* uncaughtException / unhandledRejection → a throw in OUR main-process code
* render-process-gone → the renderer died (OOM, segfault):
* the classic "window went blank//white"
* child-process-gone → GPU / utility process died
* preload-error → preload threw: `window.helder` is
* undefined and the app silently falls
* back to MOCK DATA (see CLAUDE.md)
* unresponsive → main thread wedged (the beachball)
* crashReporter minidumps → NATIVE crashes (node-pty is native,
* and a segfault there takes the whole
* process down with no JS hook at all)
*/
let fatalDialogOpen = false
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
* native crashes, and the log file should exist before anything can fail. */
export function initDiagnostics(isDev: boolean): void {
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
// set before we ask for the path or the folder is called "Electron".
app.setName('Helder')
initLogger({ dir: app.getPath('logs'), mirror: isDev })
// Native minidumps for crashes no JS handler can see. Local only — nothing is
// uploaded anywhere (there is no server, and this is a personal tool).
try {
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
} catch (e) {
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
}
logger.info('session', 'starting', {
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
platform: `${process.platform} ${process.arch}`,
packaged: app.isPackaged,
dev: isDev,
crashDumps: app.getPath('crashDumps'),
argv: process.argv.slice(1),
project: process.env.HELDER_PROJECT ?? null,
})
installProcessHooks()
installAppHooks()
}
function installProcessHooks(): void {
process.on('uncaughtException', (err, origin) => {
logger.error('fatal', `uncaughtException (${origin})`, err)
showFatal(err)
})
process.on('unhandledRejection', (reason) => {
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
// handler shows up — and the stack here is the only place the cause exists.
logger.error('fatal', 'unhandledRejection', reason)
})
process.on('warning', (w) => {
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
// a leak-driven crash.
logger.warn('node', w.name, { message: w.message, stack: w.stack })
})
app.on('before-quit', () => logger.info('session', 'quitting'))
}
function installAppHooks(): void {
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
// 'killed', 'launch-failed'.
app.on('render-process-gone', (_e, contents, details) => {
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
exitCode: details.exitCode,
reason: details.reason,
url: safeUrl(contents),
})
if (details.reason !== 'clean-exit') {
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
}
})
app.on('child-process-gone', (_e, details) => {
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
exitCode: details.exitCode,
serviceName: details.serviceName,
name: details.name,
})
})
// A preload failure is silent-by-design in Electron and the single nastiest
// failure mode this app has: no window.helder → the renderer quietly serves
// mock data and "terminal not available", as if nothing were wrong.
app.on('web-contents-created', (_e, contents) => {
contents.on('preload-error', (_ev, preloadPath, error) => {
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
})
contents.on('console-message', (...a: unknown[]) => {
// Electron ≥36 passes a single event object; older versions pass
// (event, level, message, line, sourceId). Support both so a version bump
// doesn't quietly stop capturing renderer console output.
const d = normaliseConsoleMessage(a)
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
})
})
}
/** Electron changed the console-message signature in v36; accept both shapes. */
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
const first = args[0] as Record<string, unknown> | undefined
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
const lvl = first.level
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
return {
level: asNum,
message: String(first.message),
source: String(first.sourceId ?? ''),
line: Number(first.lineNumber ?? 0),
}
}
if (args.length >= 3 && typeof args[1] === 'number') {
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
}
return null
}
function safeUrl(contents: WebContents | null): string {
try { return contents?.getURL() ?? '' } catch { return '' }
}
/** Watch for the beachball: log it (with a stack-free note) rather than let the
* user guess whether the app is hung or just slow. */
export function watchWindow(win: BrowserWindow): void {
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
win.on('responsive', () => logger.info('window', 'responsive again'))
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
})
}
/**
* Tell the user something died, and put the log one click away — a crash the
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
* stack a hundred dialogs.
*/
function showFatal(err: unknown): void {
if (fatalDialogOpen) return
fatalDialogOpen = true
const { message } = formatErr(err)
const logPath = getLogPath()
Promise.resolve(dialog.showMessageBox({
type: 'error',
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
defaultId: 0,
cancelId: logPath ? 1 : 0,
message: 'Helder hit an error',
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
})).then(({ response }) => {
if (logPath && response === 0) openLog()
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
.finally(() => { fatalDialogOpen = false })
}
/** Open the log in the default text editor. */
export function openLog(): void {
const p = getLogPath()
if (p) shell.openPath(p).catch(() => {})
}
/** Reveal the log folder (all rotated files + siblings) in Finder. */
export function revealLog(): void {
const p = getLogPath()
if (p) shell.showItemInFolder(p)
}
export { getLogDir, getLogPath }

View File

@@ -202,6 +202,32 @@ export async function readProjectFile(root: string, rel: string): Promise<string
return buf.toString('utf8') return buf.toString('utf8')
} }
const IMAGE_MIME: Record<string, string> = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', jfif: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp',
ico: 'image/x-icon', avif: 'image/avif', apng: 'image/apng',
}
const MAX_IMAGE_BYTES = 25_000_000
/** Read an image file as a `data:` URL for the viewer's <img> — the renderer
* can't touch the filesystem, and a data URL sidesteps file:// path/escaping
* concerns entirely. Returns '' for a non-image extension, a path escaping the
* root, or an oversized/unreadable file. */
export async function readImageDataUrl(root: string, rel: string): Promise<string> {
const ext = rel.split('.').pop()?.toLowerCase() ?? ''
const mime = IMAGE_MIME[ext]
if (!mime) return ''
const target = join(root, rel)
if (relative(root, target).startsWith('..')) return ''
try {
const buf = await readFile(target)
if (buf.length > MAX_IMAGE_BYTES) return ''
return `data:${mime};base64,${buf.toString('base64')}`
} catch {
return ''
}
}
/** Write a text file (relative path). Used by the editable buffer's save. */ /** Write a text file (relative path). Used by the editable buffer's save. */
export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> { export async function writeProjectFile(root: string, rel: string, content: string): Promise<void> {
await writeFile(join(root, rel), content, 'utf8') await writeFile(join(root, rel), content, 'utf8')

View File

@@ -76,19 +76,41 @@ async function git(root: string, args: string[]): Promise<string> {
return stdout return stdout
} }
/** Map a porcelain code pair to our display letter + staged flag. */ /** Map one porcelain status code to our display letter. */
export function classify(index: string, working: string): { letter: GitStatusLetter; staged: boolean } { function letterFor(code: string): GitStatusLetter {
const staged = index !== ' ' && index !== '?'
const code = staged ? index : working
let letter: GitStatusLetter
switch (code) { switch (code) {
case 'A': case 'C': case '?': letter = 'A'; break case 'A': case 'C': case '?': return 'A'
case 'D': letter = 'D'; break case 'D': return 'D'
case 'R': letter = 'R'; break case 'R': return 'R'
case 'U': letter = 'M'; break case 'U': return 'M'
case 'M': default: letter = 'M'; break default: return 'M'
} }
return { letter, staged } }
/** 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. */ /** Parse a `## ...` porcelain branch header into a display branch name. */
@@ -135,6 +157,15 @@ async function headText(root: string, path: string): Promise<string> {
} }
} }
/** 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 ''
}
}
async function diskText(root: string, path: string): Promise<string> { async function diskText(root: string, path: string): Promise<string> {
try { try {
const buf = await readFile(join(root, path)) const buf = await readFile(join(root, path))
@@ -214,14 +245,31 @@ async function doLoad(root: string): Promise<GitLoad | null> {
// re-reads ALL of them, which is the dominant cost of a reload. Run them with // 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 // bounded concurrency instead so the spawns overlap (cap keeps us well under
// macOS's low default FD limit). Order is preserved by index. // macOS's low default FD limit). Order is preserved by index.
const changes = await mapLimit(files, 12, async (f) => { const changes = (await mapLimit(files, 12, async (f) => {
const { letter, staged } = classify(f.index, f.working) const rows = classify(f.index, f.working)
const isNew = f.index === '?' || f.index === 'A' const stagedRow = rows.find((r) => r.staged)
const isDeleted = letter === 'D' const workRow = rows.find((r) => !r.staged)
const original = isNew ? '' : await headText(root, f.path) // The index blob is only needed when a file sits in BOTH groups. With one
const updated = isDeleted ? '' : await diskText(root, f.path) // row the index copy equals HEAD (unstaged only) or the disk copy (staged
return { path: f.path, status: letter, staged, original, updated } as GitChange // only), so the common case still costs no extra `git show`.
}) const both = !!stagedRow && !!workRow
const idx = both ? await indexText(root, f.path) : ''
const out: GitChange[] = []
if (stagedRow) {
// Staged row: HEAD -> index.
const original = stagedRow.letter === 'A' ? '' : await headText(root, f.path)
const updated = stagedRow.letter === 'D' ? '' : both ? idx : await diskText(root, f.path)
out.push({ path: f.path, status: stagedRow.letter, staged: true, original, updated })
}
if (workRow) {
// Unstaged row: index -> disk. An untracked file has no index copy.
const untracked = f.index === '?'
const original = untracked ? '' : both ? idx : await headText(root, f.path)
const updated = workRow.letter === 'D' ? '' : await diskText(root, f.path)
out.push({ path: f.path, status: workRow.letter, staged: false, original, updated })
}
return out
})).flat()
return { branch, changes } return { branch, changes }
} }
@@ -243,6 +291,24 @@ export async function commit(root: string, message: string): Promise<void> {
await git(root, ['commit', '-m', message]) await git(root, ['commit', '-m', message])
} }
/**
* Push the current branch to its remote. If the branch has no upstream yet,
* retry with `-u origin <branch>` so the first push also sets tracking.
* Returns a concise one-line summary for the toast (git writes progress to
* stderr, so we pull the summary from there).
*/
export async function push(root: string): Promise<{ ok: boolean; message: string }> {
let res = await runGit(root, ['push'])
if (res.code !== 0 && /no upstream branch|set-upstream/i.test(res.stderr)) {
const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
if (branch && branch !== 'HEAD') res = await runGit(root, ['push', '-u', 'origin', branch])
}
const lines = (res.stderr || res.stdout).trim().split('\n').map((l) => l.trim()).filter(Boolean)
if (res.code !== 0) return { ok: false, message: lines.pop() || 'push failed' }
const summary = lines.find((l) => /->|up-to-date|new branch/i.test(l)) || lines.pop() || 'Pushed'
return { ok: true, message: summary }
}
/** /**
* Discard working-tree changes for each path: * Discard working-tree changes for each path:
* - exists in HEAD → restore index + worktree to the last commit * - exists in HEAD → restore index + worktree to the last commit

View File

@@ -4,15 +4,23 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron' import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar' import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project' import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from './fs-service' import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { commit, discard, load, stage, unstage } from './git-service' import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service' import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
import { listFiles, searchContent } from './search-service' import { listFiles, searchContent } from './search-service'
import { readNote, writeNote } from './notes-service'
import { initDiagnostics, openLog, revealLog, watchWindow } from './diagnostics'
import { getLogPath, log, logger, type LogLevel } from './logger'
const isDev = !!process.env['ELECTRON_RENDERER_URL'] const isDev = !!process.env['ELECTRON_RENDERER_URL']
const isMac = process.platform === 'darwin' const isMac = process.platform === 'darwin'
// Before anything else can fail: start the crash reporter, open the log file and
// hook uncaughtException / render-process-gone / preload-error. Everything below
// (including a throw at module load) is logged from here on.
initDiagnostics(isDev)
const WATCH_IGNORE = new Set([ const WATCH_IGNORE = new Set([
'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt', 'node_modules', '.git', 'out', 'dist', 'build', '.next', '.nuxt',
'.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo', '.cache', 'coverage', 'vendor', '.idea', '.vscode', '.helder', '.turbo',
@@ -114,6 +122,7 @@ async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
startWatcher() startWatcher()
startConfigWatcher() startConfigWatcher()
startGitWatcher() startGitWatcher()
syncWindowTitle()
return true return true
} }
@@ -144,16 +153,20 @@ function buildAppMenu(): Menu {
{ {
label: 'View', label: 'View',
submenu: [ submenu: [
// ⌘R refreshes git status + the file explorer instead of reloading the // ⌘R refreshes the three left columns instead of reloading the window:
// window. We send the same "project changed" ping the disk watchers use, // git status (Col A), the file explorer (Col B), and the open file in the
// which makes the renderer re-read git + the file tree. Reload / Force // viewer re-read from disk (Col C). We send a dedicated `view:refresh`
// Reload are intentionally omitted so ⌘R never blows away app state. // 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', label: 'Refresh',
accelerator: 'CmdOrCtrl+R', accelerator: 'CmdOrCtrl+R',
click: (_m, win) => { click: (_m, win) => {
const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow() const bw = win instanceof BrowserWindow ? win : BrowserWindow.getFocusedWindow()
bw?.webContents.send('project:changed') bw?.webContents.send('view:refresh')
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
@@ -167,10 +180,28 @@ function buildAppMenu(): Menu {
], ],
}, },
{ role: 'windowMenu' }, { role: 'windowMenu' },
{
role: 'help',
submenu: [
// A crash you can't read is a crash you can't fix — keep the log one
// click away rather than buried in ~/Library/Logs.
{ label: 'Open Log', click: () => openLog() },
{ label: 'Reveal Log in Finder', click: () => revealLog() },
],
},
] ]
return Menu.buildFromTemplate(template) return Menu.buildFromTemplate(template)
} }
/** macOS Dock right-click menu. Sits above the system items (Show All Windows,
* Hide, Quit) that macOS appends itself. It mirrors File → New Window so a new
* project window is one right-click away, even with no window focused. */
function buildDockMenu(): Menu {
return Menu.buildFromTemplate([
{ label: 'New Window', click: () => spawnInstance() },
])
}
function startWatcher(): void { function startWatcher(): void {
if (watcher) { watcher.close(); watcher = null } if (watcher) { watcher.close(); watcher = null }
const root = getRoot() const root = getRoot()
@@ -186,53 +217,112 @@ function startWatcher(): void {
watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping) watcher.on('add', ping).on('change', ping).on('unlink', ping).on('addDir', ping).on('unlinkDir', ping)
} }
/**
* ipcMain.handle + logging. Every IPC failure used to die in a renderer-side
* `catch {}` that showed a generic toast ("Create failed") and dropped the
* actual cause, so the log records the channel, its args and the error, then
* RETHROWS so the renderer keeps behaving exactly as before.
*
* Slow calls get a line too: an FS/git handler blocking for seconds is the
* symptom that precedes a beachball, and it's invisible otherwise.
*/
const SLOW_MS = 1000
function handle(channel: string, fn: (e: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown): void {
ipcMain.handle(channel, async (e, ...args) => {
const started = Date.now()
try {
const out = await fn(e, ...(args as never[]))
const ms = Date.now() - started
if (ms >= SLOW_MS) logger.warn('ipc', `${channel} slow`, { ms, args: previewArgs(args) })
return out
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args), ms: Date.now() - started })
throw err
}
})
}
/** Log-safe args: file CONTENT (fs:write) would swamp the log, so cap length. */
function previewArgs(args: unknown[]): unknown[] {
return args.map((a) => (typeof a === 'string' && a.length > 120 ? `${a.slice(0, 120)}… (${a.length} chars)` : a))
}
/** Same, for the fire-and-forget `ipcMain.on` channels. */
function on(channel: string, fn: (e: Electron.IpcMainEvent, ...args: never[]) => void): void {
ipcMain.on(channel, (e, ...args) => {
try {
fn(e, ...(args as never[]))
} catch (err) {
logger.error('ipc', `${channel} failed`, err, { args: previewArgs(args) })
}
})
}
function registerIpc(): void { function registerIpc(): void {
ipcMain.handle('project:current', () => ({ root: getRoot(), name: getName() })) handle('project:current', () => ({ root: getRoot(), name: getName() }))
ipcMain.handle('project:open', async (e) => { handle('project:open', async (e) => {
await openFolderFlow(BrowserWindow.fromWebContents(e.sender)) await openFolderFlow(BrowserWindow.fromWebContents(e.sender))
return { root: getRoot(), name: getName() } return { root: getRoot(), name: getName() }
}) })
ipcMain.handle('projects:recent', () => getRecentProjects()) handle('projects:recent', () => getRecentProjects())
ipcMain.handle('project:openPath', async (_e, path: string) => { handle('project:openPath', async (_e, path: string) => {
setRoot(path) setRoot(path)
const r = getRoot() const r = getRoot()
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() } if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
syncWindowTitle()
broadcast('project:changed') broadcast('project:changed')
return { root: getRoot(), name: getName() } return { root: getRoot(), name: getName() }
}) })
ipcMain.handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null }) handle('fs:tree', () => { const r = getRoot(); return r ? readTree(r) : null })
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} }) handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
ipcMain.handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] }) handle('fs:readDir', (_e, rel: string) => { const r = getRoot(); return r ? readDirChildren(r, rel) : [] })
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' }) handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) }) handle('fs:imageDataUrl', (_e, rel: string) => { const r = getRoot(); return r ? readImageDataUrl(r, rel) : '' })
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) }) handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
ipcMain.handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) }) handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
ipcMain.handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) }) handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) }) handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
// Scratch note: <project>/.notes.txt, saved when the window loses focus.
handle('notes:read', () => { const r = getRoot(); return r ? readNote(r) : '' })
handle('notes:write', (_e, text: string) => { const r = getRoot(); if (r) return writeNote(r, text) })
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null }) handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
ipcMain.handle('pty:available', () => ptyAvailable()) handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
ipcMain.handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows)) handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
ipcMain.on('pty:write', (_e, id: number, data: string) => writePty(id, data)) handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) })
ipcMain.on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows)) handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) })
ipcMain.on('pty:kill', (_e, id: number) => killPty(id)) handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } })
handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
ipcMain.handle('config:get', () => getConfig()) handle('pty:available', () => ptyAvailable())
ipcMain.handle('config:theme', () => getThemeCss()) handle('pty:create', (e, kind: 'agent' | 'shell', cols: number, rows: number) => createPty(e.sender, kind, cols, rows))
on('pty:write', (_e, id: number, data: string) => writePty(id, data))
on('pty:resize', (_e, id: number, cols: number, rows: number) => resizePty(id, cols, rows))
on('pty:kill', (_e, id: number) => killPty(id))
ipcMain.handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] }) handle('config:get', () => getConfig())
ipcMain.handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) }) handle('config:theme', () => getThemeCss())
ipcMain.handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] }) handle('recent:get', () => { const r = getRoot(); return r ? getRecent(r) : [] })
ipcMain.handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] }) handle('recent:set', (_e, list: string[]) => { const r = getRoot(); if (r) return setRecent(r, list) })
ipcMain.handle('dialog:unsavedClose', async (e, path: string) => { handle('search:content', (_e, query: string) => { const r = getRoot(); return r ? searchContent(r, query) : [] })
handle('search:files', () => { const r = getRoot(); return r ? listFiles(r) : [] })
// The renderer's window into the same log file (see renderer/src/log.ts): its
// uncaught errors, promise rejections and ErrorBoundary catches land here, so
// main-process and renderer failures interleave in ONE chronological file.
on('log:write', (_e, level: LogLevel, scope: string, msg: string, ctx?: unknown) => {
log(level, scope, msg, ctx)
})
handle('log:path', () => getLogPath())
handle('log:open', () => openLog())
handle('log:reveal', () => revealLog())
handle('dialog:unsavedClose', async (e, path: string) => {
const win = BrowserWindow.fromWebContents(e.sender) const win = BrowserWindow.fromWebContents(e.sender)
const opts: Electron.MessageBoxOptions = { const opts: Electron.MessageBoxOptions = {
type: 'warning', type: 'warning',
@@ -247,6 +337,13 @@ function registerIpc(): void {
}) })
} }
/** The window title is the open project's folder name (falling back to the app
* name when nothing is open). Push it to every window after a project change. */
function syncWindowTitle(): void {
const title = getName() || 'Helder'
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
}
function createWindow(): void { function createWindow(): void {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1680, width: 1680,
@@ -255,6 +352,7 @@ function createWindow(): void {
minHeight: 680, minHeight: 680,
show: false, show: false,
backgroundColor: '#16171a', backgroundColor: '#16171a',
title: getName() || 'Helder',
titleBarStyle: isMac ? 'hiddenInset' : 'default', titleBarStyle: isMac ? 'hiddenInset' : 'default',
trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined, trafficLightPosition: isMac ? { x: 14, y: 12 } : undefined,
webPreferences: { webPreferences: {
@@ -265,8 +363,22 @@ function createWindow(): void {
}, },
}) })
// Keep the renderer's <title>Helder</title> from clobbering the folder name.
win.on('page-title-updated', (e) => e.preventDefault())
win.on('ready-to-show', () => win.show()) win.on('ready-to-show', () => win.show())
// macOS hides the traffic lights in fullscreen, so the title bar can drop the
// 82px it reserves for them. Only the main process knows this state, hence IPC.
function sendFullscreen(): void {
if (win.isDestroyed()) return
win.webContents.send('window:fullscreen', win.isFullScreen())
}
win.on('enter-full-screen', sendFullscreen)
win.on('leave-full-screen', sendFullscreen)
win.webContents.on('did-finish-load', sendFullscreen)
watchWindow(win)
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url) shell.openExternal(url)
return { action: 'deny' } return { action: 'deny' }
@@ -280,10 +392,14 @@ function createWindow(): void {
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
app.setName('Helder') // app.setName already ran in initDiagnostics — it has to happen before
// app.getPath('logs') resolves, or the log lands in ~/Library/Logs/Electron.
Menu.setApplicationMenu(buildAppMenu()) Menu.setApplicationMenu(buildAppMenu())
// app.dock exists on macOS only.
app.dock?.setMenu(buildDockMenu())
registerIpc() registerIpc()
const initialRoot = getRoot() const initialRoot = getRoot()
logger.info('session', 'ready', { root: initialRoot, logPath: getLogPath() })
if (initialRoot) { if (initialRoot) {
await resolveConfig(initialRoot) await resolveConfig(initialRoot)
await addRecentProject(initialRoot) await addRecentProject(initialRoot)
@@ -296,6 +412,10 @@ app.whenReady().then(async () => {
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow() if (BrowserWindow.getAllWindows().length === 0) createWindow()
}) })
}).catch((e) => {
// A throw in startup (bad config, unreadable project) otherwise leaves a
// window-less app with no message at all.
logger.error('session', 'startup failed', e)
}) })
app.on('window-all-closed', () => { app.on('window-all-closed', () => {

169
src/main/logger.ts Normal file
View File

@@ -0,0 +1,169 @@
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
import { join } from 'node:path'
/**
* The app's one log sink: a plain-text file under the OS log dir, written
* SYNCHRONOUSLY so a line survives the process dying moments later.
*
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
* process per project window, and every window past the first is spawned by
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
* from Finder there's no terminal attached either. Before this, a crash left
* literally no trace; that's what made "it crashed sometimes" undebuggable.
*
* This module deliberately does NOT import electron, so it stays unit-testable.
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
const MAX_BYTES = 2 * 1024 * 1024
const KEEP = 3
interface LoggerState {
file: string | null
dir: string | null
min: number
mirror: boolean
/** Byte size tracked in-process so the common path avoids a stat() per line. */
size: number
}
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
/** Absolute path of the active log file, or null before initLogger(). */
export function getLogPath(): string | null {
return state.file
}
export function getLogDir(): string | null {
return state.dir
}
/**
* Point the logger at `dir` (created if needed). Safe to call once per process.
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
* terminal IS attached. `level` gates the floor (default: everything).
*/
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
state.dir = opts.dir
state.file = join(opts.dir, 'helder.log')
state.mirror = !!opts.mirror
state.min = LEVELS[opts.level ?? 'debug']
try {
mkdirSync(opts.dir, { recursive: true })
state.size = statSync(state.file).size
} catch {
// Missing file is the normal first-run case (size stays 0). A genuinely
// unwritable dir surfaces on the first write() instead, which no-ops.
state.size = 0
}
}
/**
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
* file crosses MAX_BYTES. Several project processes share one file and could in
* principle rotate at the same moment; the renames are best-effort and a lost
* race costs at most some log lines, never a crash — hence the blanket catch.
*/
function rotate(): void {
const dir = state.dir
const file = state.file
if (!dir || !file) return
try {
const oldest = join(dir, `helder.${KEEP}.log`)
try { unlinkSync(oldest) } catch { /* wasn't there */ }
for (let i = KEEP - 1; i >= 1; i--) {
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
}
renameSync(file, join(dir, 'helder.1.log'))
state.size = 0
} catch { /* another process rotated first; keep appending */ }
}
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
function safeJson(value: unknown): string {
const seen = new WeakSet<object>()
try {
return JSON.stringify(value, (_k, v) => {
if (typeof v === 'bigint') return `${v}n`
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
if (typeof v === 'object' && v !== null) {
if (seen.has(v as object)) return '[Circular]'
seen.add(v as object)
}
return v
}) ?? String(value)
} catch {
return '[unserializable]'
}
}
/**
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
* (people throw strings), and `cause` is followed so wrapped errors keep their
* root cause — usually the line that actually explains the failure.
*/
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
if (e instanceof Error) {
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
if (e.stack) out.stack = e.stack
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
return out
}
return { message: typeof e === 'string' ? e : safeJson(e) }
}
/**
* One log line: ISO ts · level · pid · scope · message · context JSON.
*
* Continuation lines are indented, never bare: a message can carry newlines of
* its own (Electron's console warnings do, and so does any stack passed as the
* message), and an unindented second line is indistinguishable from a new entry
* to both a human skimming the file and to `grep`.
*/
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
if (ctx === undefined) return head + '\n'
return head + ' ' + indent(safeJson(ctx)) + '\n'
}
function indent(s: string): string {
return s.replace(/\r?\n/g, '\n ')
}
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
if (LEVELS[level] < state.min) return
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
if (state.mirror) {
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
fn(line.trimEnd())
}
const file = state.file
if (!file) return
if (state.size + line.length > MAX_BYTES) rotate()
try {
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
// concurrent appends from sibling project processes don't interleave.
appendFileSync(file, line, { encoding: 'utf8' })
state.size += Buffer.byteLength(line)
} catch { /* disk full / no permission — never let logging break the app */ }
}
export const logger = {
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
}
/** Reset for tests. Not used by the app. */
export function _resetLogger(): void {
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
}

28
src/main/notes-service.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Project scratch note: a plain text file at `<project>/.notes.txt`.
*
* Deliberately not JSON and not part of `.helder/`. It is a note the user
* writes by hand, so it must stay readable and editable outside Helder.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { logger } from './logger'
export const NOTES_FILE = '.notes.txt'
/** The note's text. Empty string when the project has no note yet. */
export async function readNote(root: string): Promise<string> {
try {
return await readFile(join(root, NOTES_FILE), 'utf8')
} catch (err) {
// ENOENT is the normal "no note yet" case, anything else is worth knowing.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn('notes', 'read failed', { root, err: String(err) })
}
return ''
}
}
export async function writeNote(root: string, text: string): Promise<void> {
await writeFile(join(root, NOTES_FILE), text, 'utf8')
}

View File

@@ -2,6 +2,7 @@ import { createRequire } from 'node:module'
import type { WebContents } from 'electron' import type { WebContents } from 'electron'
import { getRoot } from './project' import { getRoot } from './project'
import { getConfig } from './config' import { getConfig } from './config'
import { logger } from './logger'
/** /**
* Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the * Real PTYs. The agent pane is a shell that auto-launches the `claude` CLI; the
@@ -19,10 +20,14 @@ let pty: PtyModule | null = null
try { try {
pty = require('node-pty') as PtyModule pty = require('node-pty') as PtyModule
} catch (e) { } catch (e) {
console.error('[helder] node-pty unavailable — run `npm run rebuild`:', (e as Error).message) logger.error('pty', 'node-pty unavailable — run `npm run rebuild`', e)
} }
const terms = new Map<number, import('node-pty').IPty>() const terms = new Map<number, import('node-pty').IPty>()
/** Ids we killed on purpose (pane closed, window quitting, StrictMode remount).
* Their exit is expected, so it must NOT be logged as a warning — a log full of
* false alarms is a log nobody reads. */
const killing = new Set<number>()
let seq = 0 let seq = 0
/** /**
@@ -45,6 +50,13 @@ function ptyEnv(): { [key: string]: string } {
if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) { if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) {
env.LANG = 'en_US.UTF-8' env.LANG = 'en_US.UTF-8'
} }
// Same inheritance gap as locale, but for color: a terminal launch leaks
// COLORTERM=truecolor so `claude` renders its UI backgrounds as exact 24-bit
// colors; the GUI-launched packaged app has none, so claude falls back to a
// 256/16-color approximation and the same backgrounds shift shade. Match both.
if (process.platform !== 'win32' && !env.COLORTERM) {
env.COLORTERM = 'truecolor'
}
return env return env
} }
@@ -60,7 +72,10 @@ export function ptyAvailable(): boolean {
} }
export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number { export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: number, rows: number): number {
if (!pty) return -1 if (!pty) {
logger.warn('pty', `create(${kind}) refused — node-pty never loaded`)
return -1
}
const cwd = getRoot() || process.env.HOME || process.cwd() const cwd = getRoot() || process.env.HOME || process.cwd()
const shell = defaultShell() const shell = defaultShell()
const ai = getConfig().ai const ai = getConfig().ai
@@ -71,7 +86,7 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
// echoed command cluttering the pane; claude takes over a clean terminal. // echoed command cluttering the pane; claude takes over a clean terminal.
const args = launchAgent ? ['-i', '-c', ai.command] : [] const args = launchAgent ? ['-i', '-c', ai.command] : []
const proc = pty.spawn(shell, args, { const proc = pty.spawn(shell, args, {
name: 'xterm-color', name: 'xterm-256color',
cols: cols || 80, cols: cols || 80,
rows: rows || 24, rows: rows || 24,
cwd, cwd,
@@ -79,9 +94,21 @@ export function createPty(sender: WebContents, kind: 'agent' | 'shell', cols: nu
}) })
const id = ++seq const id = ++seq
terms.set(id, proc) terms.set(id, proc)
logger.info('pty', `spawned ${kind}`, { id, pid: proc.pid, shell, args, cwd })
proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) }) proc.onData((data) => { if (!sender.isDestroyed()) sender.send('pty:data', { id, data }) })
proc.onExit(() => { terms.delete(id); if (!sender.isDestroyed()) sender.send('pty:exit', { id }) }) proc.onExit(({ exitCode, signal }) => {
terms.delete(id)
// The agent pane dying on its own (`claude` not on PATH, OOM-killed,
// segfault) looks from the UI like "the terminal just went blank" — the exit
// code and signal are the only evidence of what actually happened. An exit we
// asked for is routine, so only an unrequested one is a warning.
const expected = killing.delete(id)
const abnormal = !expected && (exitCode !== 0 || (signal != null && signal !== 0))
if (abnormal) logger.warn('pty', `${kind} exited unexpectedly`, { id, exitCode, signal })
else logger.info('pty', `${kind} exited`, { id, exitCode, expected })
if (!sender.isDestroyed()) sender.send('pty:exit', { id })
})
// Windows path keeps the type-into-shell launch (no `-i -c` semantics there). // Windows path keeps the type-into-shell launch (no `-i -c` semantics there).
if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') { if (kind === 'agent' && ai.autoLaunch && process.platform === 'win32') {
@@ -100,10 +127,10 @@ export function resizePty(id: number, cols: number, rows: number): void {
export function killPty(id: number): void { export function killPty(id: number): void {
const p = terms.get(id) const p = terms.get(id)
if (p) { try { p.kill() } catch { /* already gone */ } terms.delete(id) } if (p) { killing.add(id); try { p.kill() } catch { /* already gone */ } terms.delete(id) }
} }
export function killAllPtys(): void { export function killAllPtys(): void {
for (const p of terms.values()) { try { p.kill() } catch { /* noop */ } } for (const [id, p] of terms) { killing.add(id); try { p.kill() } catch { /* noop */ } }
terms.clear() terms.clear()
} }

View File

@@ -25,6 +25,7 @@ const api = {
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path), readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
files: () => ipcRenderer.invoke('fs:files'), files: () => ipcRenderer.invoke('fs:files'),
read: (path: string) => ipcRenderer.invoke('fs:read', path), read: (path: string) => ipcRenderer.invoke('fs:read', path),
imageDataUrl: (path: string): Promise<string> => ipcRenderer.invoke('fs:imageDataUrl', path),
write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content), write: (path: string, content: string): Promise<void> => ipcRenderer.invoke('fs:write', path, content),
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path), delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path), create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
@@ -35,11 +36,16 @@ const api = {
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) }, reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
}, },
notes: {
read: (): Promise<string> => ipcRenderer.invoke('notes:read'),
write: (text: string): Promise<void> => ipcRenderer.invoke('notes:write', text),
},
git: { git: {
load: () => ipcRenderer.invoke('git:load'), load: () => ipcRenderer.invoke('git:load'),
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths), stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths), unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
commit: (message: string) => ipcRenderer.invoke('git:commit', message), commit: (message: string) => ipcRenderer.invoke('git:commit', message),
push: (): Promise<{ ok: boolean; message: string }> => ipcRenderer.invoke('git:push'),
discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths), discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
}, },
@@ -82,6 +88,18 @@ const api = {
ipcRenderer.invoke('dialog:unsavedClose', path), ipcRenderer.invoke('dialog:unsavedClose', path),
}, },
/** Renderer errors → the main process log file (see renderer/src/log.ts).
* `write` is fire-and-forget on purpose: logging must never await, and must
* never be able to reject into the very handler that's reporting a failure. */
log: {
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown): void => {
ipcRenderer.send('log:write', level, scope, msg, ctx)
},
path: (): Promise<string | null> => ipcRenderer.invoke('log:path'),
open: (): Promise<void> => ipcRenderer.invoke('log:open'),
reveal: (): Promise<void> => ipcRenderer.invoke('log:reveal'),
},
/** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */ /** Subscribe to "the project changed on disk" pings. Returns an unsubscribe. */
onProjectChanged: (cb: () => void): (() => void) => { onProjectChanged: (cb: () => void): (() => void) => {
const handler = (): void => cb() const handler = (): void => cb()
@@ -95,6 +113,20 @@ const api = {
ipcRenderer.on('config:changed', handler) ipcRenderer.on('config:changed', handler)
return () => ipcRenderer.removeListener('config:changed', handler) return () => ipcRenderer.removeListener('config:changed', handler)
}, },
/** Subscribe to the window entering/leaving fullscreen. Returns an unsubscribe. */
onFullscreen: (cb: (on: boolean) => void): (() => void) => {
const handler = (_e: unknown, on: boolean): void => cb(on)
ipcRenderer.on('window:fullscreen', handler)
return () => ipcRenderer.removeListener('window:fullscreen', handler)
},
/** Subscribe to explicit ⌘R refresh requests (git + tree + viewer). Returns an unsubscribe. */
onRefresh: (cb: () => void): (() => void) => {
const handler = (): void => cb()
ipcRenderer.on('view:refresh', handler)
return () => ipcRenderer.removeListener('view:refresh', handler)
},
} }
if (process.contextIsolated) { if (process.contextIsolated) {

View File

@@ -5,12 +5,13 @@ import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor' import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor' import type { Cursor, Mode, Selection } from './editor'
import { Terminal, lid } from './terminals' import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, PassPopup, SearchModal, Toasts } from './overlays' import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, Toasts } from './overlays'
import { ProjectLauncher } from './launcher' import { ProjectLauncher } from './launcher'
import type { Menu, Toast } from './overlays' import type { Menu, Toast } from './overlays'
import type { FileNode, GitStatus } from './types' import type { DiffSide, FileNode, GitStatus } from './types'
import { useProject, useProjectActions } from './project' import { useProject, useProjectActions } from './project'
import { HL } from './highlight' import { HL } from './highlight'
import { rlog } from './log'
import { loadJson, loadNum, saveJson, saveNum } from './persist' import { loadJson, loadNum, saveJson, saveNum } from './persist'
const NO_COMMITTED = new Set<string>() const NO_COMMITTED = new Set<string>()
@@ -76,11 +77,19 @@ export function App(): React.ReactElement {
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({}) const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
// Which git row opened each tab. Only Diff and Split follow it: Original and
// Actual always show HEAD and the file on disk.
const [tabSide, setTabSide] = useState<Record<string, DiffSide>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set()) const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null) const [cursor, setCursor] = useState<Cursor | null>(null)
const [selection, setSelection] = useState<Selection | null>(null) const [selection, setSelection] = useState<Selection | null>(null)
const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | null>(null) const [overlay, setOverlay] = useState<'search' | 'history' | 'help' | 'projects' | 'notes' | null>(null)
const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection const [searchInit, setSearchInit] = useState('') // seed query for ⌘F-with-selection
// Project scratch note (.notes.txt). savedNote tracks what is on disk, so a
// blur with no edits does not rewrite the file (and wake the fs watcher).
const [note, setNote] = useState('')
const noteRef = useRef(note); noteRef.current = note
const savedNote = useRef('')
// Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator. // Most-recently-opened files, newest first, de-duplicated. Drives the ⌘↓/⌘↑ navigator.
const [history, setHistory] = useState<string[]>([]) const [history, setHistory] = useState<string[]>([])
const [histInitSel, setHistInitSel] = useState(0) const [histInitSel, setHistInitSel] = useState(0)
@@ -95,11 +104,15 @@ export function App(): React.ReactElement {
// Brief full-screen "branch - repository" flash whenever the window gains focus // Brief full-screen "branch - repository" flash whenever the window gains focus
// (handy when juggling several project windows). // (handy when juggling several project windows).
const [showFlash, setShowFlash] = useState(false) const [showFlash, setShowFlash] = useState(false)
// Fullscreen on macOS hides the traffic lights, so the title bar reclaims the
// space they reserve. Main tells us; the browser preview simply stays false.
const [fullscreen, setFullscreen] = useState(false)
// Editable buffers: path → current text (absent = clean, showing on-disk content). // Editable buffers: path → current text (absent = clean, showing on-disk content).
const [buffers, setBuffers] = useState<Record<string, string>>({}) const [buffers, setBuffers] = useState<Record<string, string>>({})
const buffersRef = useRef(buffers); buffersRef.current = buffers const buffersRef = useRef(buffers); buffersRef.current = buffers
const projRef = useRef(proj); projRef.current = proj const projRef = useRef(proj); projRef.current = proj
const activeRef = useRef(active); activeRef.current = active
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
function diskText(path: string): string { return proj.files[path] ?? '' } function diskText(path: string): string { return proj.files[path] ?? '' }
@@ -124,7 +137,10 @@ export function App(): React.ReactElement {
}).catch(() => {}) }).catch(() => {})
actions.refreshGit() actions.refreshGit()
}) })
.catch(() => toast('Save failed', path)) // A failed save is the one error here that can lose work, so it gets the
// reason logged (permissions, read-only volume, file vanished) — the toast
// alone can't say why.
.catch((e) => { rlog.error('save', 'write failed', e, { path, bytes: text.length }); toast('Save failed', path) })
} }
function saveActive(): void { function saveActive(): void {
if (!active) return if (!active) return
@@ -208,10 +224,11 @@ export function App(): React.ReactElement {
// currently-visible nodes (honouring expansion + the hidden-files toggle). // currently-visible nodes (honouring expansion + the hidden-files toggle).
const gitNav = useMemo(() => { const gitNav = useMemo(() => {
const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path)) const visible = proj.changes.filter((c) => !NO_COMMITTED.has(c.path))
const stagedRows = visible.filter((c) => proj.staged.has(c.path)) const stagedRows = visible.filter((c) => c.staged)
const changeRows = visible.filter((c) => !proj.staged.has(c.path)) const changeRows = visible.filter((c) => !c.staged)
return [...stagedRows, ...changeRows].map((c) => c.path) // Rows, not paths: one file can sit in both groups (staged, then edited again).
}, [proj.changes, proj.staged]) return [...stagedRows, ...changeRows].map((c) => ({ id: c.id, path: c.path, staged: c.staged }))
}, [proj.changes])
const treeNav = useMemo(() => { const treeNav = useMemo(() => {
const out: { path: string; type: 'dir' | 'file' }[] = [] const out: { path: string; type: 'dir' | 'file' }[] = []
const walk = (node: FileNode): void => { const walk = (node: FileNode): void => {
@@ -226,9 +243,28 @@ export function App(): React.ReactElement {
if (proj.tree) walk(proj.tree) if (proj.tree) walk(proj.tree)
return out return out
}, [proj.tree, openDirs, showHidden]) }, [proj.tree, openDirs, showHidden])
const gitSelPath = gitNav[gitSel] ?? null const gitSelRow = gitNav[gitSel] ?? null
const gitSelPath = gitSelRow?.path ?? null
const treeSelItem = treeNav[treeSel] ?? null const treeSelItem = treeNav[treeSel] ?? null
// Clicking a row moves the keyboard cursor onto it. Without this the cursor
// stays at index 0, so the top row of the list keeps its highlight next to
// whichever row the click actually selected.
const syncGitSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.git-row') as HTMLElement | null
const id = row?.dataset.rowId
if (!id) return
const i = gitNav.findIndex((r) => r.id === id)
if (i >= 0) setGitSel(i)
}
const syncTreeSel = (target: EventTarget): void => {
const row = (target as HTMLElement).closest?.('.tree-row') as HTMLElement | null
const path = row?.dataset.rowPath
if (path == null) return
const i = treeNav.findIndex((r) => r.path === path)
if (i >= 0) setTreeSel(i)
}
// Keep the row cursors in range as the lists shrink/grow. // Keep the row cursors in range as the lists shrink/grow.
useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length]) useEffect(() => { setGitSel((s) => Math.min(s, Math.max(0, gitNav.length - 1))) }, [gitNav.length])
useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length]) useEffect(() => { setTreeSel((s) => Math.min(s, Math.max(0, treeNav.length - 1))) }, [treeNav.length])
@@ -236,21 +272,35 @@ export function App(): React.ReactElement {
useEffect(() => { useEffect(() => {
if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' }) if (activePanel === 'git') document.querySelector('.git-row.kbd')?.scrollIntoView({ block: 'nearest' })
}, [gitSel, activePanel]) }, [gitSel, activePanel])
useEffect(() => {
if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' })
}, [treeSel, activePanel])
// Flash the branch · repository banner for ~2s each time the window gains focus. // Flash the branch · repository banner for ~2s each time the window *regains*
// focus. We gate on a prior blur so the banner never shows on startup (or on
// any focus event fired during launch) — only on a genuine "welcome back".
useEffect(() => { useEffect(() => {
let timer: ReturnType<typeof setTimeout> let timer: ReturnType<typeof setTimeout>
let wasBlurred = false
function flash(): void { function flash(): void {
if (!wasBlurred) return // first-time-after-open (or launch focus): skip
wasBlurred = false
setShowFlash(true) setShowFlash(true)
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(() => setShowFlash(false), 2000) timer = setTimeout(() => setShowFlash(false), 2000)
} }
if (document.hasFocus()) flash() function onBlur(): void { wasBlurred = true }
window.addEventListener('focus', flash) window.addEventListener('focus', flash)
return () => { window.removeEventListener('focus', flash); clearTimeout(timer) } window.addEventListener('blur', onBlur)
return () => {
window.removeEventListener('focus', flash)
window.removeEventListener('blur', onBlur)
clearTimeout(timer)
}
}, [])
// Follow the window's fullscreen state (see the title-bar padding in styles.css).
useEffect(() => {
const subscribe = window.helder?.onFullscreen
if (!subscribe) return
return subscribe((on) => setFullscreen(on))
}, []) }, [])
// Open/reopen a project with a fully collapsed tree: seed the expansion set // Open/reopen a project with a fully collapsed tree: seed the expansion set
@@ -273,15 +323,47 @@ export function App(): React.ReactElement {
if (!proj.ready || sessionRoot.current === proj.root) return if (!proj.ready || sessionRoot.current === proj.root) return
sessionRoot.current = proj.root sessionRoot.current = proj.root
recentReady.current = false recentReady.current = false
setHistory([]); setActive(null); setTabMode({}) setHistory([]); setActive(null); setTabMode({}); setTabSide({})
if (proj.config.session.restoreOnLaunch) { if (proj.config.session.restoreOnLaunch) {
const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode> } | null>(`helder.session:${proj.root}`, null) const saved = loadJson<{ active: string | null; tabMode: Record<string, Mode>; tabSide?: Record<string, DiffSide> } | null>(`helder.session:${proj.root}`, null)
if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}) } if (saved) { setActive(saved.active ?? null); setTabMode(saved.tabMode ?? {}); setTabSide(saved.tabSide ?? {}) }
} }
const bridge = window.helder const bridge = window.helder
if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true }) if (bridge) bridge.recent.get().then((list) => { setHistory(list); recentReady.current = true }).catch(() => { recentReady.current = true })
}, [proj.ready, proj.root, proj.config.session.restoreOnLaunch]) }, [proj.ready, proj.root, proj.config.session.restoreOnLaunch])
// Write the note to <project>/.notes.txt. Skipped when nothing changed, so a
// plain alt-tab does not touch the file or wake the project watcher.
const saveNote = useCallback((): void => {
const bridge = window.helder
if (!bridge || !projRef.current.root) return
const text = noteRef.current
if (text === savedNote.current) return
savedNote.current = text
bridge.notes.write(text).catch((e) => rlog.error('notes', 'save failed', e))
}, [])
// The note is saved when the window loses focus. beforeunload covers the other
// way out — closing the window or quitting, which never fires a blur.
useEffect(() => {
window.addEventListener('blur', saveNote)
window.addEventListener('beforeunload', saveNote)
return () => {
window.removeEventListener('blur', saveNote)
window.removeEventListener('beforeunload', saveNote)
}
}, [saveNote])
// Load this project's note. Each window holds one project, so this runs once
// per project change.
useEffect(() => {
const bridge = window.helder
if (!bridge || !proj.root) { setNote(''); savedNote.current = ''; return }
bridge.notes.read()
.then((t) => { setNote(t); savedNote.current = t })
.catch((e) => rlog.error('notes', 'load failed', e))
}, [proj.root])
// Persist the history to .helder/recent.json (newest first, capped to 100 in main), // Persist the history to .helder/recent.json (newest first, capped to 100 in main),
// but only once it's been loaded for this project (so we never clobber it with []). // but only once it's been loaded for this project (so we never clobber it with []).
useEffect(() => { useEffect(() => {
@@ -292,8 +374,8 @@ export function App(): React.ReactElement {
useEffect(() => { useEffect(() => {
if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return if (sessionRoot.current !== proj.root || !proj.config.session.restoreOnLaunch) return
saveJson(`helder.session:${proj.root}`, { active, tabMode }) saveJson(`helder.session:${proj.root}`, { active, tabMode, tabSide })
}, [active, tabMode, proj.root, proj.config.session.restoreOnLaunch]) }, [active, tabMode, tabSide, proj.root, proj.config.session.restoreOnLaunch])
function toast(title: string, ref?: string): void { function toast(title: string, ref?: string): void {
const id = lid() const id = lid()
@@ -330,6 +412,13 @@ export function App(): React.ReactElement {
setCommitMsg('') setCommitMsg('')
} }
function push(): void {
toast('Pushing…')
actions.push()
.then((r) => toast(r.ok ? 'Pushed' : 'Push failed', r.message))
.catch((e) => toast('Push failed', String(e?.message ?? e)))
}
function revealInFinder(path: string): void { function revealInFinder(path: string): void {
window.helder?.shell.reveal(path) window.helder?.shell.reveal(path)
} }
@@ -337,7 +426,7 @@ export function App(): React.ReactElement {
async function deleteEntry(path: string, isDir: boolean): Promise<void> { async function deleteEntry(path: string, isDir: boolean): Promise<void> {
const bridge = window.helder const bridge = window.helder
if (bridge) { if (bridge) {
try { await bridge.fs.delete(path) } catch { toast('Delete failed', path); return } try { await bridge.fs.delete(path) } catch (e) { rlog.error('fs', 'delete failed', e, { path, isDir }); toast('Delete failed', path); return }
} }
const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/')) const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/'))
setHistory((h) => h.filter((p) => !inside(p))) setHistory((h) => h.filter((p) => !inside(p)))
@@ -356,7 +445,7 @@ export function App(): React.ReactElement {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '') const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '')
const bridge = window.helder const bridge = window.helder
if (bridge) { if (bridge) {
try { await bridge.fs.create(rel) } catch { toast('Create failed', rel); return } try { await bridge.fs.create(rel) } catch (e) { rlog.error('fs', 'create file failed', e, { path: rel }); toast('Create failed', rel); return }
} }
if (dir) setOpenDirs((d) => { const n = new Set(d); n.add(dir); return n }) if (dir) setOpenDirs((d) => { const n = new Set(d); n.add(dir); return n })
actions.refresh() actions.refresh()
@@ -371,7 +460,7 @@ export function App(): React.ReactElement {
const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '').replace(/\/+$/, '') const rel = (dir ? dir + '/' : '') + name.replace(/^\/+/, '').replace(/\/+$/, '')
const bridge = window.helder const bridge = window.helder
if (bridge) { if (bridge) {
try { await bridge.fs.mkdir(rel) } catch { toast('Create failed', rel); return } try { await bridge.fs.mkdir(rel) } catch (e) { rlog.error('fs', 'create folder failed', e, { path: rel }); toast('Create failed', rel); return }
} }
setOpenDirs((d) => { const n = new Set(d); if (dir) n.add(dir); n.add(rel); return n }) setOpenDirs((d) => { const n = new Set(d); if (dir) n.add(dir); n.add(rel); return n })
actions.refresh() actions.refresh()
@@ -397,7 +486,7 @@ export function App(): React.ReactElement {
actions.unstage(p) actions.unstage(p)
} }
function openFile(path: string, opts: { diff?: boolean; line?: number } = {}): void { function openFile(path: string, opts: { diff?: boolean; line?: number; side?: DiffSide } = {}): void {
const changed = !!proj.diffs[path] const changed = !!proj.diffs[path]
setFocusZone('editor') setFocusZone('editor')
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100)) setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
@@ -407,6 +496,14 @@ export function App(): React.ReactElement {
// Unchanged files only have the plain editable "code" view. // Unchanged files only have the plain editable "code" view.
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code' const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
setTabMode((m) => ({ ...m, [path]: openMode })) setTabMode((m) => ({ ...m, [path]: openMode }))
// Remember which git row this came from, so Diff/Split show that half. An
// explorer click carries no side and falls back to the whole file.
setTabSide((m) => {
const n = { ...m }
if (opts.side) n[path] = opts.side
else delete n[path]
return n
})
reveal(path) reveal(path)
if (opts.line) { if (opts.line) {
// The updated/code views render in the CodeEditor (a textarea over a <pre>), // The updated/code views render in the CodeEditor (a textarea over a <pre>),
@@ -492,7 +589,8 @@ export function App(): React.ReactElement {
} }
if (target.kind === 'git') { if (target.kind === 'git') {
items.push({ sep: true }) items.push({ sep: true })
const isStaged = proj.staged.has(target.path) // The row carries its own flag: a file can have a staged and an unstaged row.
const isStaged = target.staged ?? proj.staged.has(target.path)
items.push(isStaged items.push(isStaged
? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) } ? { icon: Icon.minus({ style: { color: 'var(--mod)' } }), label: 'Unstage changes', onClick: () => unstageGuarded(target.path) }
: { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) }) : { icon: Icon.plus({ style: { color: 'var(--add)' } }), label: 'Stage changes', onClick: () => stageGuarded(target.path) })
@@ -532,7 +630,7 @@ export function App(): React.ReactElement {
if (activePanel === 'git' && gitSelPath) { if (activePanel === 'git' && gitSelPath) {
const row = document.querySelector('.git-row.kbd') as HTMLElement | null const row = document.querySelector('.git-row.kbd') as HTMLElement | null
const r = row?.getBoundingClientRect() const r = row?.getBoundingClientRect()
openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: proj.staged.has(gitSelPath) }) openMenuAt(row ? rowAnchorX(row) : 220, r ? r.top + 4 : 120, { path: gitSelPath, kind: 'git', staged: gitSelRow?.staged })
return true return true
} }
if (activePanel === 'tree' && treeSelItem) { if (activePanel === 'tree' && treeSelItem) {
@@ -545,7 +643,7 @@ export function App(): React.ReactElement {
} }
// ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder. // ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
function openPanelSelection(): boolean { function openPanelSelection(): boolean {
if (activePanel === 'git' && gitSelPath) { openFile(gitSelPath, { diff: true }); return true } if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { diff: true, side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
if (activePanel === 'tree' && treeSelItem) { if (activePanel === 'tree' && treeSelItem) {
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path) if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
else openFile(treeSelItem.path) else openFile(treeSelItem.path)
@@ -628,6 +726,18 @@ export function App(): React.ReactElement {
} }
return false return false
} }
// ⌘P with the note open hands the whole note to the agent. Same route as the
// editor's Pass on to Agent: bracketed paste, so nothing is submitted. The note
// is saved and closed, so you see the text land in the agent composer.
function passNote(): boolean {
const text = noteRef.current.trim()
if (!text) return false
window.dispatchEvent(new CustomEvent('agentPaste', { detail: text }))
setOverlay(null)
saveNote()
toast('Note passed to agent', '.notes.txt')
return true
}
function hasSelection(): boolean { function hasSelection(): boolean {
if ((window.getSelection()?.toString() ?? '') !== '') return true if ((window.getSelection()?.toString() ?? '') !== '') return true
const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null const ae = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null
@@ -642,8 +752,8 @@ export function App(): React.ReactElement {
const meta = e.metaKey || e.ctrlKey const meta = e.metaKey || e.ctrlKey
const ae = document.activeElement as HTMLElement | null const ae = document.activeElement as HTMLElement | null
const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') const inField = !!ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA')
// The history navigator owns the keyboard while open (it listens in capture phase). // The history / project navigators own the keyboard while open (capture phase).
if (overlay === 'history') return if (overlay === 'history' || overlay === 'projects') return
// An open context menu owns the keyboard (arrows / ↵ / esc handled there). // An open context menu owns the keyboard (arrows / ↵ / esc handled there).
if (menu) return if (menu) return
const inPanel = activePanel === 'git' || activePanel === 'tree' const inPanel = activePanel === 'git' || activePanel === 'tree'
@@ -662,10 +772,16 @@ export function App(): React.ReactElement {
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (splitFor) setSplitFor(null) if (splitFor) setSplitFor(null)
// Closing the note saves it there and then, rather than leaving the text
// to wait for the next blur.
else if (overlay === 'notes') { setOverlay(null); saveNote() }
else if (overlay) setOverlay(null) else if (overlay) setOverlay(null)
else setMenu(null) else setMenu(null)
return return
} }
// ⌘P with the note open passes the note text to the agent. This runs before
// the global ⌘P (push), so the note wins while its overlay is up.
if (overlay === 'notes' && meta && e.key.toLowerCase() === 'p') { e.preventDefault(); passNote(); return }
// Search / help modals own the keyboard while open (they handle their own keys). // Search / help modals own the keyboard while open (they handle their own keys).
if (overlay) return if (overlay) return
@@ -675,9 +791,16 @@ export function App(): React.ReactElement {
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0) setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
setOverlay('history') setOverlay('history')
} }
// ⌘F and ⌘P both open the unified search (it covers file names too), seeded // ⇧⌘O opens the recent-project history picker (⌘O — opening a new folder —
// with the current selection when there is one. // is the native File-menu accelerator, so the renderer is free to own ⇧⌘O).
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') } else if (meta && e.shiftKey && e.key.toLowerCase() === 'o') { e.preventDefault(); setOverlay('projects') }
// ⌘F opens the unified search (it covers file names too), seeded with the
// current selection when there is one.
else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
// ⌘P pushes the current branch to its remote.
else if (meta && e.key.toLowerCase() === 'p') { e.preventDefault(); push() }
// ⌘N opens the project note.
else if (meta && e.key.toLowerCase() === 'n') { e.preventDefault(); setOverlay('notes') }
else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() } else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() }
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) } else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
// ⌘D deletes the current file (with confirmation). // ⌘D deletes the current file (with confirmation).
@@ -693,7 +816,7 @@ export function App(): React.ReactElement {
else if (meta && e.key === 'Enter') { else if (meta && e.key === 'Enter') {
if (ae && ae.classList.contains('commit-input')) return if (ae && ae.classList.contains('commit-input')) return
e.preventDefault() e.preventDefault()
if (commitMsg.trim() && proj.changes.some((c) => proj.staged.has(c.path))) commit() if (commitMsg.trim() && proj.changes.some((c) => c.staged)) commit()
} }
// ⌘C focuses the commit message (but let native copy run when there's a selection). // ⌘C focuses the commit message (but let native copy run when there's a selection).
else if (meta && e.key.toLowerCase() === 'c') { else if (meta && e.key.toLowerCase() === 'c') {
@@ -714,7 +837,61 @@ export function App(): React.ReactElement {
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel]) }, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
// ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
// left columns: git status (A) + the file tree (B) via a full project reload,
// and the open file in the viewer (C) re-read from disk. The viewer reload
// drops any in-memory buffer so the editable view shows on-disk truth — an
// explicit refresh is exactly when the user wants whatever the agent just
// wrote, the same "on-disk wins" rule used on file open / mode change.
// A refresh reads from disk, so nothing may visibly change — the columns then
// look inert and the keypress feels lost. Flash A/B/C light grey for ~250 ms so
// ⌘R always reads as "that landed". Two class names (a/b) alternate because a
// CSS animation only restarts when the animation-name changes: on a second ⌘R
// inside the window, re-adding the same class would replay nothing.
const [flashTick, setFlashTick] = useState(0)
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const flashClass = flashTick === 0 ? '' : (flashTick % 2 ? ' refresh-flash-a' : ' refresh-flash-b')
useEffect(() => () => { if (flashTimer.current) clearTimeout(flashTimer.current) }, [])
useEffect(() => {
if (!window.helder) return
return window.helder.onRefresh(() => {
actions.refresh()
const path = activeRef.current
if (path) reloadFromDisk(path)
setFlashTick((n) => n + 1)
if (flashTimer.current) clearTimeout(flashTimer.current)
flashTimer.current = setTimeout(() => setFlashTick(0), 400)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Re-read git status the moment the Git column (Col A) gains focus, so it
// reflects on-disk truth whenever the user turns to it — e.g. after the agent
// rewrote files while focus was elsewhere. Git-only fast path (no tree/index
// re-walk); the FS watcher stays the backstop for everything else.
useEffect(() => {
if (activePanel === 'git') actions.refreshGit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePanel])
// Poll git status on an interval as a backstop for the FS watcher: an external
// tool (the agent, the git CLI) rewriting files *should* fire the watcher's
// project:changed, but a missed filesystem event would otherwise leave the Git
// column stale until the next manual ⌘R. Interval is config-driven
// (git.refreshInterval ms; 0 disables). Fast path — git status only.
useEffect(() => {
if (!window.helder) return
const ms = proj.config.git.refreshInterval
if (!ms || ms <= 0) return
const id = setInterval(() => actions.refreshGit(), ms)
return () => clearInterval(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proj.config.git.refreshInterval])
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code') const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
// Which half the open tab shows. Also lights up the matching git row.
const activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : [] const crumb = active ? active.split('/') : []
@@ -726,7 +903,7 @@ export function App(): React.ReactElement {
return ( return (
<div className="app"> <div className="app">
{/* title bar */} {/* title bar */}
<div className="titlebar"> <div className={'titlebar' + (fullscreen ? ' fullscreen' : '')}>
<div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div> <div className="traffic"><i className="r" /><i className="y" /><i className="g" /></div>
<div className="tb-title"> <div className="tb-title">
<b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b> <b style={{ color: 'var(--accent)', cursor: 'pointer', textTransform: 'uppercase' }} title="Open folder…" onClick={() => actions.openFolder()}>{proj.name}</b>
@@ -742,15 +919,19 @@ export function App(): React.ReactElement {
<div className="tb-actions"> <div className="tb-actions">
<button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }} <button className={'tb-btn tb-toggle' + (overlay === 'search' ? ' on' : '')} onClick={() => { setSearchInit(''); setOverlay('search') }}
title="Search contents & names"> title="Search contents & names">
{Icon.search()} Search <span className="tb-state">{overlay === 'search' ? 'On' : 'Off'}</span> <kbd>F</kbd> {Icon.search()} Search <kbd>F</kbd>
</button> </button>
<button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)} <button className={'tb-btn tb-toggle' + (autoResize ? ' on' : '')} onClick={() => setAutoResize((v) => !v)}
title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}> title={autoResize ? 'Auto-fit panels: on — columns re-fit on resize/focus. Click to lock current sizes.' : 'Auto-fit panels: off — sizes locked. Click to re-enable.'}>
{Icon.layout()} Auto-fit <span className="tb-state">{autoResize ? 'On' : 'Off'}</span> <kbd>A</kbd> {Icon.layout()} Auto-fit <kbd>A</kbd>
</button> </button>
<button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)} <button className={'tb-btn tb-toggle' + (showHidden ? ' on' : '')} onClick={() => setShowHidden((v) => !v)}
title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}> title={showHidden ? 'Hidden files: shown — dotfiles appear in the tree and search. Click to hide.' : 'Hidden files: hidden — dotfiles excluded from the tree and search. Click to show.'}>
{Icon.eye()} Hidden <span className="tb-state">{showHidden ? 'On' : 'Off'}</span> <kbd>.</kbd> {Icon.eye()} Hidden <kbd>.</kbd>
</button>
<button className={'tb-btn tb-toggle' + (overlay === 'notes' ? ' on' : '')} onClick={() => setOverlay('notes')}
title="Project note (.notes.txt) — kept next to this project">
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd>
</button> </button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button> <button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div> </div>
@@ -770,18 +951,18 @@ export function App(): React.ReactElement {
{/* workbench */} {/* workbench */}
<div className="workbench"> <div className="workbench">
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '')} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }} <div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
onMouseDownCapture={() => setActivePanel('git')}> onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}>
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED} <GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg} commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} /> kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} /> <Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '')} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }} <div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={() => setActivePanel('tree')}> onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}>
{proj.tree ? ( {proj.tree ? (
<FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile} <FileTree tree={proj.tree} openDirs={openDirs} toggleDir={toggleDir} onOpen={openFile}
onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
@@ -792,8 +973,8 @@ export function App(): React.ReactElement {
</div> </div>
<Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} /> <Splitter onDelta={(dx) => { setAutoResize(false); setTreeW((w) => clamp(w + dx, 160, 520)) }} />
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '')} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}> <div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} <Editor active={active} mode={mode} side={activeSide}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }} setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
onContext={openMenu} onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active} onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
@@ -812,7 +993,7 @@ export function App(): React.ReactElement {
</div> </div>
{/* overlays */} {/* overlays */}
{splitFor && <SplitView path={splitFor} onClose={() => setSplitFor(null)} onContext={openMenu} />} {splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code} {passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
onConfirm={(payload) => { onConfirm={(payload) => {
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload })) window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))
@@ -828,7 +1009,9 @@ export function App(): React.ReactElement {
onCancel={() => setNewFolderPopup(null)} />} onCancel={() => setNewFolderPopup(null)} />}
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />} {overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
{overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />} {overlay === 'history' && <HistoryModal history={history} initialSel={histInitSel} onOpen={openFile} onClose={() => setOverlay(null)} changeSet={changeSet} />}
{overlay === 'projects' && <ProjectsModal recents={proj.recents} currentRoot={proj.root} onOpen={(p) => actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
{overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />} {overlay === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
{overlay === 'notes' && <NotesModal text={note} onChange={setNote} onClose={() => { setOverlay(null); saveNote() }} />}
{confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />} {confirm && <ConfirmModal title={confirm.title} body={confirm.body} confirmLabel={confirm.confirmLabel} danger onConfirm={confirm.onConfirm} onClose={() => setConfirm(null)} />}
{menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />} {menu && <ContextMenu menu={menu} onClose={() => setMenu(null)} />}
<Toasts toasts={toasts} /> <Toasts toasts={toasts} />

View File

@@ -1,6 +1,6 @@
/* Shared icons, FileIcon, GitPanel, FileTree */ /* Shared icons, FileIcon, GitPanel, FileTree */
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import type { Change, FileNode, GitStatus } from './types' import type { Change, DiffSide, FileNode, GitStatus } from './types'
import { HL } from './highlight' import { HL } from './highlight'
type SvgProps = React.SVGProps<SVGSVGElement> type SvgProps = React.SVGProps<SVGSVGElement>
@@ -23,10 +23,12 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>), check: (p) => (<svg width="13" height="13" viewBox="0 0 14 14" fill="none" {...p}><path d="M2.5 7.5l2.8 3L11.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg>),
discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>), discard: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M12.5 5.5A5 5 0 1 0 13 9" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><path d="M12.5 2.5v3h-3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>), layout: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3" width="12" height="10" rx="1.5" stroke="currentColor" strokeWidth="1.3" /><path d="M6 3v10M10 3v10" stroke="currentColor" strokeWidth="1.3" /></svg>),
note: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><path d="M3.5 2.5h9v11h-9v-11z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>), help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>), trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>), finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>), eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
} }
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
@@ -54,7 +56,7 @@ export function FileIcon({ path }: { path: string }): React.ReactElement {
} }
/* Shared callback signatures used across panels. */ /* Shared callback signatures used across panels. */
export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number }) => void export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number; side?: DiffSide }) => void
export interface ContextTarget { export interface ContextTarget {
path: string path: string
kind: 'editor' | 'dir' | 'file' | 'git' kind: 'editor' | 'dir' | 'file' | 'git'
@@ -66,23 +68,28 @@ export interface ContextTarget {
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */ /* ============ Git / Source Control panel ============ */
function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onContext, onToggleStage }: { function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
c: Change c: Change
staged: boolean
activePath: string | null activePath: string | null
/** Which half the open tab is showing, so only that row lights up. */
activeSide: DiffSide | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null kbdId: string | null
showDir: boolean showDir: boolean
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
onToggleStage: (path: string) => void onToggleStage: (path: string) => void
}): React.ReactElement { }): React.ReactElement {
const staged = c.staged
const side: DiffSide = staged ? 'staged' : 'unstaged'
const name = c.path.split('/').pop() const name = c.path.split('/').pop()
const dir = c.path.split('/').slice(0, -1).join('/') const dir = c.path.split('/').slice(0, -1).join('/')
const dirShown = showDir && !!dir const dirShown = showDir && !!dir
const isActive = activePath === c.path && (!activeSide || activeSide === side)
return ( return (
<div className={'git-row' + (activePath === c.path ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdPath === c.path ? ' kbd' : '')} <div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
onClick={() => onOpen(c.path, { diff: true })} data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })} onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
title={c.path}> title={c.path}>
<span className={'git-stat ' + c.status}>{c.status}</span> <span className={'git-stat ' + c.status}>{c.status}</span>
@@ -97,10 +104,9 @@ function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onCo
) )
} }
export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: { export function GitPanel({ branch, changes, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, activeSide, ctxPath, kbdId, showDir }: {
branch: string branch: string
changes: Change[] changes: Change[]
staged: Set<string>
committed: Set<string> committed: Set<string>
commitMsg: string commitMsg: string
setCommitMsg: (v: string) => void setCommitMsg: (v: string) => void
@@ -109,16 +115,18 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
onStageAll: () => void onStageAll: () => void
onUnstageAll: () => void onUnstageAll: () => void
onCommit: () => void onCommit: () => void
onPush: () => void
onOpen: OpenFile onOpen: OpenFile
onContext: OnContext onContext: OnContext
activePath: string | null activePath: string | null
activeSide: DiffSide | null
ctxPath: string | null ctxPath: string | null
kbdPath: string | null kbdId: string | null
showDir: boolean showDir: boolean
}): React.ReactElement { }): React.ReactElement {
const visible = changes.filter((c) => !committed.has(c.path)) const visible = changes.filter((c) => !committed.has(c.path))
const stagedList = visible.filter((c) => staged.has(c.path)) const stagedList = visible.filter((c) => c.staged)
const changesList = visible.filter((c) => !staged.has(c.path)) const changesList = visible.filter((c) => !c.staged)
const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }) const totals = visible.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0 const canCommit = stagedList.length > 0 && commitMsg.trim().length > 0
@@ -129,6 +137,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
placeholder="Shift+Enter to commit" placeholder="Shift+Enter to commit"
onChange={(e) => setCommitMsg(e.target.value)} onChange={(e) => setCommitMsg(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} /> onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
</div> </div>
<div className="git-body"> <div className="git-body">
@@ -141,7 +150,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>} {stagedList.length > 0 && <button className="grp-act" title="Unstage all" onClick={onUnstageAll}>{Icon.minus()}</button>}
</div> </div>
{stagedList.length > 0 ? stagedList.map((c) => ( {stagedList.length > 0 ? stagedList.map((c) => (
<GitRow key={c.path} c={c} staged={true} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir} <GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onUnstage} />
)) : ( )) : (
<div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div> <div className="git-none">Nothing staged use <span className="key">+</span> to stage a file</div>
@@ -154,7 +163,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
{changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>} {changesList.length > 0 && <button className="grp-act" title="Stage all" onClick={onStageAll}>{Icon.plus()}</button>}
</div> </div>
{changesList.length > 0 ? changesList.map((c) => ( {changesList.length > 0 ? changesList.map((c) => (
<GitRow key={c.path} c={c} staged={false} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} showDir={showDir} <GitRow key={c.id} c={c} activePath={activePath} activeSide={activeSide} ctxPath={ctxPath} kbdId={kbdId} showDir={showDir}
onOpen={onOpen} onContext={onContext} onToggleStage={onStage} /> onOpen={onOpen} onContext={onContext} onToggleStage={onStage} />
)) : ( )) : (
<div className="git-none">All changes staged</div> <div className="git-none">All changes staged</div>
@@ -196,6 +205,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
<Fragment> <Fragment>
{node.path !== '' && ( {node.path !== '' && (
<div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }} <div className={'tree-row folder' + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} style={{ paddingLeft: pad }}
data-row-path={node.path}
onClick={() => toggleDir(node.path)} onClick={() => toggleDir(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}> onContextMenu={(e) => onContext(e, { path: node.path, kind: 'dir' })}>
<span className="tw"><Chevron open={isOpen} /></span> <span className="tw"><Chevron open={isOpen} /></span>
@@ -217,6 +227,7 @@ function TreeNode({ node, depth, openDirs, toggleDir, onOpen, onContext, activeP
return ( return (
<div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')} <div className={'tree-row' + (activePath === node.path ? ' active' : '') + (ctxPath === node.path ? ' ctx' : '') + (kbdPath === node.path ? ' kbd' : '')}
style={{ paddingLeft: pad + 2 }} style={{ paddingLeft: pad + 2 }}
data-row-path={node.path}
onClick={() => onOpen(node.path)} onClick={() => onOpen(node.path)}
onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })} onContextMenu={(e) => onContext(e, { path: node.path, kind: 'file' })}
title={node.path}> title={node.path}>

View File

@@ -6,6 +6,7 @@
* same original/updated text pair per changed file, so keep buildDiff()'s * same original/updated text pair per changed file, so keep buildDiff()'s
* output shape. */ * output shape. */
import type { Change, Diff, FileNode, Project } from './types' import type { Change, Diff, FileNode, Project } from './types'
import { rowId } from './types'
import { buildDiff } from './diff' import { buildDiff } from './diff'
// ---- working-tree (current / updated) file contents ---------------- // ---- working-tree (current / updated) file contents ----------------
@@ -651,7 +652,8 @@ const changes: Change[] = changeDefs.map((c) => {
original: orig, original: orig,
updated: upd, updated: upd,
}) })
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' } // Mock rows are all unstaged here; project.tsx flips the staged ones over.
return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D', staged: false, id: rowId(c.path, false) }
}) })
export const PROJECT: Project = { export const PROJECT: Project = {

View File

@@ -1,6 +1,7 @@
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */ /* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useMemo, useRef } from 'react' import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import type { Diff, ViewLine } from './types' import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project' import { useProject } from './project'
import { HL } from './highlight' import { HL } from './highlight'
import { renderMarkdown } from './markdown' import { renderMarkdown } from './markdown'
@@ -47,6 +48,48 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
if (top < s.scrollTop) s.scrollTop = top - 20 if (top < s.scrollTop) s.scrollTop = top - 20
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20 else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20
} }
/* Tab indents, it does not move focus out of the editor.
* Plain Tab on one line inserts spaces. Tab over a multi-line selection
* indents every line it touches. Shift+Tab outdents.
* We write through execCommand so the browser keeps its own undo history. */
function replace(ta: HTMLTextAreaElement, from: number, to: number, text: string): void {
ta.setSelectionRange(from, to)
if (document.execCommand?.('insertText', false, text)) return
// No execCommand (jsdom): splice by hand. Costs the native undo step.
onChange(ta.value.slice(0, from) + text + ta.value.slice(to))
}
function handleTab(e: React.KeyboardEvent<HTMLTextAreaElement>, out: boolean): void {
e.preventDefault()
const ta = e.currentTarget
const pad = ' '.repeat(tabSize)
const from = ta.selectionStart
const to = ta.selectionEnd
if (!out && !ta.value.slice(from, to).includes('\n')) {
replace(ta, from, to, pad)
ta.setSelectionRange(from + pad.length, from + pad.length)
ensureCaretVisible(ta)
return
}
// Rewrite whole lines, so grow the range to the line edges first. A
// selection that stops at column 0 leaves that last line alone.
const start = ta.value.lastIndexOf('\n', from - 1) + 1
const tail = to > from && ta.value[to - 1] === '\n' ? to - 1 : to
const nl = ta.value.indexOf('\n', tail)
const end = nl === -1 ? ta.value.length : nl
const lines = ta.value.slice(start, end).split('\n')
const lead = new RegExp(`^(\t| {1,${tabSize}})`)
const cut = (line: string): number => (out ? (lead.exec(line)?.[0].length ?? 0) : 0)
const next = lines.map((line) => (out ? line.slice(cut(line)) : pad + line)).join('\n')
if (next === ta.value.slice(start, end)) return
const head = out ? -cut(lines[0]) : pad.length
const total = out ? -lines.reduce((n, line) => n + cut(line), 0) : pad.length * lines.length
replace(ta, start, end, next)
ta.setSelectionRange(Math.max(start, from + head), Math.max(start, to + total))
ensureCaretVisible(ta)
}
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
if (e.key === 'Tab' && !e.metaKey && !e.ctrlKey && !e.altKey) handleTab(e, e.shiftKey)
}
function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void { function handleContext(e: React.MouseEvent<HTMLTextAreaElement>): void {
e.preventDefault() e.preventDefault()
const ta = e.currentTarget const ta = e.currentTarget
@@ -71,6 +114,7 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off" <textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off" style={{ tabSize }} wrap="off" style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }} onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)} onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)} onClick={(e) => ensureCaretVisible(e.currentTarget)}
onContextMenu={handleContext} /> onContextMenu={handleContext} />
@@ -91,6 +135,32 @@ function MarkdownView({ path, text, onContext }: { path: string; text: string; o
) )
} }
/* Image preview: fetches the file as a data: URL from main (the renderer can't
* read the filesystem) and shows it centred on the editor surface. Read-only. */
function ImageView({ path, onContext }: { path: string; onContext: OnContext }): React.ReactElement {
const [src, setSrc] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let alive = true
setSrc(''); setFailed(false)
const bridge = window.helder
if (!bridge) { setFailed(true); return }
bridge.fs.imageDataUrl(path)
.then((url) => { if (alive) { if (url) setSrc(url); else setFailed(true) } })
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [path])
return (
<div className="img-view" onContextMenu={(e) => { e.preventDefault(); onContext(e, { path, kind: 'editor', line: 1 }) }}>
{src
? <img className="img-view-img" src={src} alt={path.split('/').pop()} />
: failed
? <div className="empty-ed"><div className="big" style={{ color: 'var(--fg-3)' }}>Cant preview this image</div></div>
: null}
</div>
)
}
/* Generic pane: renders an array of line descriptors with selection + caret + context. */ /* Generic pane: renders an array of line descriptors with selection + caret + context. */
function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: { function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string cacheKey: string
@@ -197,7 +267,9 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
) )
} }
/* Build the line descriptors for a given mode. */ /* Build the line descriptors for a given mode. The caller picks which diff to
* pass: Original and Actual always get the file-level HEAD-vs-disk pair, while
* Diff gets the pair of the git row you clicked. */
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } { function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false } if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false } if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
@@ -218,9 +290,11 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs return segs
} }
export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: { export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, bufferText, onEdit }: {
active: string | null active: string | null
mode: Mode mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */
side: DiffSide | null
setMode: (m: Mode) => void setMode: (m: Mode) => void
onContext: OnContext onContext: OnContext
onSplit: (path: string) => void onSplit: (path: string) => void
@@ -234,10 +308,16 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const tab = active ? { path: active } : null const tab = active ? { path: active } : null
const isImage = tab ? HL.isImage(tab.path) : false
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
// Original / Actual always show the whole file: HEAD vs disk.
const diff = tab ? PROJECT.diffs[tab.path] : null const diff = tab ? PROJECT.diffs[tab.path] : null
// Diff / Split show the half you clicked in the git panel. A file with only
// one row has an identical pair either way.
const rowDiff = (tab && side ? PROJECT.rowDiffs[rowId(tab.path, side === 'staged')] : null) || diff
const bothSides = !!tab && !!PROJECT.rowDiffs[rowId(tab.path, true)] && !!PROJECT.rowDiffs[rowId(tab.path, false)]
const lang = tab ? HL.langFor(tab.path) : null const lang = tab ? HL.langFor(tab.path) : null
const hasDiff = !!(change && diff) const hasDiff = !isImage && !!(change && diff)
const isMarkdown = lang === 'markdown' const isMarkdown = lang === 'markdown'
const segments = segmentsFor(hasDiff, isMarkdown) const segments = segmentsFor(hasDiff, isMarkdown)
@@ -249,16 +329,24 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
else effMode = 'code' else effMode = 'code'
// The diff actually on screen: the row pair for Diff, the whole file otherwise.
const shown = effMode === 'diff' ? rowDiff : diff
let built: { lines: ViewLine[]; showSign: boolean } | null = null let built: { lines: ViewLine[]; showSign: boolean } | null = null
if (tab && effMode !== 'preview') { if (tab && effMode !== 'preview') {
if (hasDiff) built = buildLines(effMode, diff, PROJECT.files[tab.path]) if (hasDiff) built = buildLines(effMode, shown, PROJECT.files[tab.path])
else built = buildLines('code', null, PROJECT.files[tab.path]) else built = buildLines('code', null, PROJECT.files[tab.path])
} }
const statusWord = change ? (change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified') : '' // File-level status, taken from the whole-file diff rather than a single row:
// a file can be staged as modified and deleted on disk at the same time.
const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status
const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified'
const activeSeg = splitOpen ? 'split' : effMode const activeSeg = splitOpen ? 'split' : effMode
const emptyUpdated = effMode === 'updated' && built && built.lines.length === 0 // Keyed off the git status, not the line count: an empty file that still exists
const emptyOriginal = effMode === 'original' && built && built.lines.length === 0 // (a just-created one, or one emptied by hand) has zero lines too, and must get
// 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. // Editable in the live-buffer modes; Original/Diff/Preview stay read-only views.
const editable = effMode === 'code' || effMode === 'updated' const editable = effMode === 'code' || effMode === 'updated'
@@ -268,11 +356,13 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
<div className="empty-ed"> <div className="empty-ed">
<div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div> <div style={{ opacity: 0.5 }}>{Icon.file({ width: 30, height: 30 })}</div>
<div className="big">No file open</div> <div className="big">No file open</div>
{/* Only what works with no file open — Copy reference and Pass on to
Agent need a file, so they are not advertised here. */}
<div className="klist"> <div className="klist">
<div><span>Open folder</span><kbd> O</kbd></div> <div><span>Open folder</span><kbd>O</kbd></div>
<div><span>Search files &amp; content</span><kbd> F</kbd></div> <div><span>Recent projects</span><kbd>O</kbd></div>
<div><span>Copy reference</span><kbd>right-click</kbd></div> <div><span>Search files &amp; content</span><kbd>F</kbd></div>
<div><span>Pass on to Agent</span><kbd>right-click</kbd></div> <div><span>Project note</span><kbd>N</kbd></div>
</div> </div>
</div> </div>
) : ( ) : (
@@ -282,13 +372,19 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
<div className="diff-bar"> <div className="diff-bar">
{change ? ( {change ? (
<Fragment> <Fragment>
<span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{statusWord}</span> <span className={'git-stat ' + fileStatus} style={{ width: 'auto' }}>{statusWord}</span>
{change.add > 0 && <span className="a">+{change.add}</span>} {!!shown && shown.add > 0 && <span className="a">+{shown.add}</span>}
{change.del > 0 && <span className="d">{change.del}</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> </Fragment>
) : ( ) : (
<span className="db-lang">{HL.langLabel(tab.path)}</span> <span className="db-lang">{isImage ? 'Image' : HL.langLabel(tab.path)}</span>
)} )}
{!isImage && (
<div className="seg"> <div className="seg">
{segments.map((s) => ( {segments.map((s) => (
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button> <button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
@@ -300,8 +396,11 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
</button> </button>
)} )}
</div> </div>
)}
</div> </div>
{effMode === 'preview' ? ( {isImage ? (
<ImageView path={tab.path} onContext={onContext} />
) : effMode === 'preview' ? (
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} /> <MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
) : emptyUpdated ? ( ) : emptyUpdated ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div> <div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div>
@@ -310,7 +409,7 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
) : editable ? ( ) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} /> <CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} />
) : ( ) : (
built && <PaneView cacheKey={tab.path + ':' + effMode} path={tab.path} lines={built.lines} built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} cursor={cursor} selection={selection} lang={lang} showSign={built.showSign} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} /> setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
)} )}
@@ -321,20 +420,24 @@ export function Editor({ active, mode, setMode, onContext, onSplit, splitOpen, c
} }
/* Full-screen side-by-side split view */ /* Full-screen side-by-side split view */
export function SplitView({ path, onClose, onContext }: { export function SplitView({ path, side, onClose, onContext }: {
path: string path: string
/** Which git row opened this file. Split compares that row's pair. */
side: DiffSide | null
onClose: () => void onClose: () => void
onContext: OnContext onContext: OnContext
}): React.ReactElement { }): React.ReactElement {
const PROJECT = useProject() const PROJECT = useProject()
const diff = PROJECT.diffs[path] const diff = (side ? PROJECT.rowDiffs[rowId(path, side === 'staged')] : null) || PROJECT.diffs[path]
const lang = HL.langFor(path) const lang = HL.langFor(path)
const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null), rightRef = useRef<HTMLDivElement>(null)
const lock = useRef(false) const lock = useRef(false)
const change = PROJECT.changes.find((c) => c.path === path) const change = PROJECT.changes.find((c) => c.path === path)
const splitStatus = diff.deleted ? 'D' : diff.added ? 'A' : 'M'
const bothSides = !!PROJECT.rowDiffs[rowId(path, true)] && !!PROJECT.rowDiffs[rowId(path, false)]
const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path]) const leftHtml = useMemo(() => diff.split.map((r) => r.l ? HL.hlLine(r.l.text, lang) : ''), [path, side])
const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path]) const rightHtml = useMemo(() => diff.split.map((r) => r.r ? HL.hlLine(r.r.text, lang) : ''), [path, side])
function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void { function sync(from: HTMLDivElement | null, to: HTMLDivElement | null): void {
if (lock.current || !from || !to) return if (lock.current || !from || !to) return
@@ -356,9 +459,10 @@ export function SplitView({ path, onClose, onContext }: {
<div className="split-head"> <div className="split-head">
<FileIcon path={path} /> <FileIcon path={path} />
<span className="sh-name">{path}</span> <span className="sh-name">{path}</span>
{change && <span className={'git-stat ' + change.status} style={{ width: 'auto' }}>{change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified'}</span>} {change && <span className={'git-stat ' + splitStatus} style={{ width: 'auto' }}>{splitStatus === 'A' ? 'Added' : splitStatus === 'D' ? 'Deleted' : 'Modified'}</span>}
{change && change.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{change.add}</span>} {diff.add > 0 && <span className="a" style={{ fontFamily: 'var(--mono)', color: 'var(--add)' }}>+{diff.add}</span>}
{change && change.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>{change.del}</span>} {diff.del > 0 && <span className="d" style={{ fontFamily: 'var(--mono)', color: 'var(--del)' }}>{diff.del}</span>}
{bothSides && <span className="db-side">{side === 'unstaged' ? 'staged → actual' : 'HEAD → staged'}</span>}
<button className="split-exit" onClick={onClose}> <button className="split-exit" onClick={onClose}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg> <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M7 1.5h3.5V5M5 10.5H1.5V7M10.5 1.5L7 5M1.5 10.5L5 7" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /></svg>
Collapse <kbd>Esc</kbd> Collapse <kbd>Esc</kbd>

View File

@@ -23,6 +23,7 @@ interface HelderBridge {
readDir: (path: string) => Promise<FileNode[]> readDir: (path: string) => Promise<FileNode[]>
files: () => Promise<Record<string, string>> files: () => Promise<Record<string, string>>
read: (path: string) => Promise<string> read: (path: string) => Promise<string>
imageDataUrl: (path: string) => Promise<string>
write: (path: string, content: string) => Promise<void> write: (path: string, content: string) => Promise<void>
delete: (path: string) => Promise<void> delete: (path: string) => Promise<void>
create: (path: string) => Promise<void> create: (path: string) => Promise<void>
@@ -31,11 +32,16 @@ interface HelderBridge {
shell: { shell: {
reveal: (path: string) => void reveal: (path: string) => void
} }
notes: {
read: () => Promise<string>
write: (text: string) => Promise<void>
}
git: { git: {
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null> load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
stage: (paths: string[]) => Promise<void> stage: (paths: string[]) => Promise<void>
unstage: (paths: string[]) => Promise<void> unstage: (paths: string[]) => Promise<void>
commit: (message: string) => Promise<void> commit: (message: string) => Promise<void>
push: () => Promise<{ ok: boolean; message: string }>
discard: (paths: string[]) => Promise<void> discard: (paths: string[]) => Promise<void>
} }
pty: { pty: {
@@ -62,8 +68,16 @@ interface HelderBridge {
dialog: { dialog: {
unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'> unsavedClose: (path: string) => Promise<'save' | 'discard' | 'cancel'>
} }
log: {
write: (level: 'debug' | 'info' | 'warn' | 'error', scope: string, msg: string, ctx?: unknown) => void
path: () => Promise<string | null>
open: () => Promise<void>
reveal: () => Promise<void>
}
onFullscreen: (cb: (on: boolean) => void) => () => void
onProjectChanged: (cb: () => void) => () => void onProjectChanged: (cb: () => void) => () => void
onConfigChanged: (cb: () => void) => () => void onConfigChanged: (cb: () => void) => () => void
onRefresh: (cb: () => void) => () => void
} }
declare global { declare global {

View File

@@ -1,24 +1,29 @@
import React from 'react' import React from 'react'
import { rlog } from './log'
interface State { interface State {
error: Error | null error: Error | null
stack: string | null
} }
/** Catches render-time errors anywhere in the tree and shows a dark, recoverable /** Catches render-time errors anywhere in the tree and shows a dark, recoverable
* panel instead of a blank window. */ * panel instead of a blank window. */
export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> { export class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
state: State = { error: null } state: State = { error: null, stack: null }
static getDerivedStateFromError(error: Error): State { static getDerivedStateFromError(error: Error): State {
return { error } return { error, stack: null }
} }
componentDidCatch(error: Error, info: React.ErrorInfo): void { componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[helder] render error:', error, info.componentStack) // The component stack is the part that says WHICH panel blew up — it exists
// only here, so it has to be logged now or it's gone.
rlog.error('react', 'render error', error, { componentStack: info.componentStack })
this.setState({ stack: info.componentStack ?? null })
} }
render(): React.ReactNode { render(): React.ReactNode {
const { error } = this.state const { error, stack } = this.state
if (!error) return this.props.children if (!error) return this.props.children
return ( return (
<div style={{ <div style={{
@@ -30,11 +35,18 @@ export class ErrorBoundary extends React.Component<{ children: React.ReactNode }
maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left', maxWidth: 720, maxHeight: 280, overflow: 'auto', margin: 0, padding: 14, textAlign: 'left',
fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)', fontFamily: 'var(--code-font)', fontSize: 12, color: 'var(--del)',
background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap', background: 'var(--bg-2)', border: '1px solid var(--border-2)', borderRadius: 8, whiteSpace: 'pre-wrap',
}}>{error.message}</pre> }}>{(error.stack || error.message) + (stack ? '\n' + stack : '')}</pre>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => location.reload()} style={{ <button onClick={() => location.reload()} style={{
background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600, background: 'var(--accent)', color: '#0c1320', border: 0, borderRadius: 7, fontWeight: 600,
padding: '7px 14px', cursor: 'pointer', fontSize: 12, padding: '7px 14px', cursor: 'pointer', fontSize: 12,
}}>Reload</button> }}>Reload</button>
{/* The panel shows this one error; the log has what led up to it. */}
<button onClick={() => window.helder?.log.open()} style={{
background: 'var(--bg-3)', color: 'var(--fg-1)', border: '1px solid var(--border-2)', borderRadius: 7,
padding: '7px 14px', cursor: 'pointer', fontSize: 12,
}}>Open Log</button>
</div>
</div> </div>
) )
} }

View File

@@ -34,6 +34,12 @@ function ext(path: string): string {
return i >= 0 ? base.slice(i + 1).toLowerCase() : '' return i >= 0 ? base.slice(i + 1).toLowerCase() : ''
} }
// Files the viewer renders as a picture (<img>) rather than as text/code.
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'apng', 'jfif'])
function isImage(path: string): boolean {
return IMAGE_EXT.has(ext(path))
}
function langFor(path: string): string | null { function langFor(path: string): string | null {
return EXT_LANG[ext(path)] || null return EXT_LANG[ext(path)] || null
} }
@@ -112,4 +118,4 @@ function iconFor(path: string): IconMeta {
return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' } return ICONS[ext(path)] || { c: '#7d838c', t: base.slice(0, 2) || '·' }
} }
export const HL = { ext, langFor, langLabel, hlLine, hlText, iconFor, escapeHtml } export const HL = { ext, langFor, langLabel, isImage, hlLine, hlText, iconFor, escapeHtml }

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

@@ -0,0 +1,75 @@
/**
* Renderer → main log bridge. Everything here ends up in the SAME file the main
* process writes, so a crash reads as one chronological story ("git:load failed
* … then the render process went OOM") instead of two disconnected halves.
*
* Without this, a renderer exception only ever reached DevTools — which nobody
* has open at the moment things actually break.
*/
type Level = 'debug' | 'info' | 'warn' | 'error'
interface ErrLike { message: string; stack?: string; name?: string }
/** Structured-clone-safe: an Error survives IPC as `{}` unless unpacked here. */
function pack(e: unknown): ErrLike | { value: string } {
if (e instanceof Error) {
const out: ErrLike = { message: e.message, name: e.name }
if (e.stack) out.stack = e.stack
return out
}
if (typeof e === 'string') return { value: e }
try { return { value: JSON.stringify(e) ?? String(e) } } catch { return { value: String(e) } }
}
function send(level: Level, scope: string, msg: string, ctx?: unknown): void {
const bridge = window.helder
// No bridge = browser preview (or a broken preload). Console is all we have.
if (!bridge?.log) {
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
fn(`[helder] ${scope}: ${msg}`, ctx ?? '')
return
}
try { bridge.log.write(level, scope, msg, ctx) } catch { /* never let logging throw */ }
}
export const rlog = {
debug: (scope: string, msg: string, ctx?: unknown): void => send('debug', scope, msg, ctx),
info: (scope: string, msg: string, ctx?: unknown): void => send('info', scope, msg, ctx),
warn: (scope: string, msg: string, ctx?: unknown): void => send('warn', scope, msg, ctx),
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
send('error', scope, msg, err === undefined ? ctx : { ...ctx, err: pack(err) }),
}
let installed = false
/** Hook the renderer's global failure paths. Call once, as early as possible. */
export function installErrorLogging(): void {
if (installed) return
installed = true
// Uncaught throws outside React's render phase: event handlers, timers, and
// the async IPC callbacks that make up most of this app.
window.addEventListener('error', (e) => {
// Resource load failures (a missing font/image) arrive here with no `error`
// and target the element — worth a line, but they aren't exceptions.
if (e.error === undefined && e.target && e.target !== window) {
const el = e.target as HTMLElement & { src?: string; href?: string }
rlog.warn('resource', `failed to load ${el.tagName?.toLowerCase?.() ?? 'resource'}`, { url: el.src || el.href || '' })
return
}
rlog.error('renderer', e.message || 'uncaught error', e.error, { source: e.filename, line: e.lineno, col: e.colno })
}, true)
// The one that matters most here: every window.helder.* call is a promise, so
// a rejected IPC with no .catch() lands here and nowhere else.
window.addEventListener('unhandledrejection', (e) => {
rlog.error('renderer', 'unhandled promise rejection', e.reason)
})
rlog.info('renderer', 'window loaded', {
url: location.href,
bridge: !!window.helder,
screen: `${window.innerWidth}x${window.innerHeight}`,
})
}

View File

@@ -14,6 +14,10 @@ import './styles.css'
import { App } from './App' import { App } from './App'
import { ProjectProvider } from './project' import { ProjectProvider } from './project'
import { ErrorBoundary } from './error-boundary' import { ErrorBoundary } from './error-boundary'
import { installErrorLogging } from './log'
// Before the first render, so an exception during mount is already captured.
installErrorLogging()
createRoot(document.getElementById('root') as HTMLElement).render( createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>

View File

@@ -54,6 +54,40 @@ function inline(src: string): string {
return s.replace(SENT_RE, (_m, i) => codes[+i]) 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 { export function renderMarkdown(text: string): string {
const lines = text.replace(/\r\n?/g, '\n').split('\n') const lines = text.replace(/\r\n?/g, '\n').split('\n')
const out: string[] = [] const out: string[] = []
@@ -86,6 +120,28 @@ export function renderMarkdown(text: string): string {
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flushPara(); out.push('<hr />'); 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)) { if (/^\s*>/.test(line)) {
flushPara() flushPara()
const buf: string[] = [] const buf: string[] = []

View File

@@ -1,6 +1,7 @@
/* Overlays: combined search (content + file names), context menu, toast, pass-popup */ /* Overlays: combined search (content + file names), context menu, toast, pass-popup */
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react' import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { useProject } from './project' import { useProject } from './project'
import type { RecentProject } from './project'
import { fuzzy } from './fuzzy' import { fuzzy } from './fuzzy'
import { FileIcon, Icon } from './components' import { FileIcon, Icon } from './components'
import type { OpenFile } from './components' import type { OpenFile } from './components'
@@ -347,6 +348,64 @@ export function HistoryModal({ history, initialSel, onOpen, onClose, changeSet }
) )
} }
/* Project history picker (⇧⌘O). Same keyboard model as the launcher and the
* recent-files navigator: ⌘↑/⌘↓ (or plain arrows) move, ↵ switches the current
* window to that project, esc closes. The currently-open project is filtered
* out — reopening it is a no-op. */
export function ProjectsModal({ recents, currentRoot, onOpen, onClose }: {
recents: RecentProject[]
currentRoot: string | null
onOpen: (path: string) => void
onClose: () => void
}): React.ReactElement {
const list = useMemo(() => recents.filter((p) => p.path !== currentRoot), [recents, currentRoot])
const [sel, setSel] = useState(0)
const selRef = useRef(sel); selRef.current = sel
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, list.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') { e.preventDefault(); const p = list[selRef.current]; if (p) { onOpen(p.path); onClose() } }
else if (e.key === 'Escape') { e.preventDefault(); onClose() }
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [list, onOpen, onClose])
useEffect(() => {
const el = listRef.current && listRef.current.querySelector('.hist-row.sel')
if (el) el.scrollIntoView({ block: 'nearest' })
}, [sel])
return (
<div className="scrim" onMouseDown={onClose}>
<div className="history-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.reveal({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Open recent project</span>
<span className="mode-chip">{list.length} project{list.length === 1 ? '' : 's'} · <kbd></kbd> <kbd></kbd> <kbd></kbd></span>
</div>
<div className="hist-list" ref={listRef}>
{list.length === 0 && <div className="pempty">No other recent projects</div>}
{list.map((p, i) => (
<div key={p.path} className={'hist-row' + (i === sel ? ' sel' : '')} title={p.path}
onMouseEnter={() => setSel(i)}
onClick={() => { onOpen(p.path); onClose() }}>
{Icon.reveal()}
<div className="hist-txt">
<span className="fn">{p.name}</span>
<span className="fd">{p.path}</span>
</div>
</div>
))}
</div>
</div>
</div>
)
}
/* Keyboard-shortcuts reference (opened from the title-bar ? button). */ /* Keyboard-shortcuts reference (opened from the title-bar ? button). */
const SHORTCUTS: { keys: string[]; label: string }[] = [ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' }, { keys: ['⌘', 'F'], label: 'Search contents & names (seeded by selection)' },
@@ -361,12 +420,16 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
{ keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' }, { keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' }, { keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' }, { keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'S'], label: 'Save the current file' }, { keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' }, { keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' }, { keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
{ keys: ['⌘', 'N'], label: 'Open the project note (.notes.txt, saved on focus loss)' },
{ keys: ['⌘', '→'], label: 'Note: pass the whole note to the agent' },
{ keys: ['⌘', 'O'], label: 'Open a project folder' }, { keys: ['⌘', 'O'], label: 'Open a project folder' },
{ keys: ['⇧', '⌘', 'O'], label: 'Open a recent project (history picker)' },
{ keys: ['Esc'], label: 'Close an overlay / split view' }, { keys: ['Esc'], label: 'Close an overlay / split view' },
] ]
@@ -377,7 +440,7 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
<div className="pi"> <div className="pi">
{Icon.help({ style: { color: 'var(--fg-3)' } })} {Icon.help({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Keyboard shortcuts</span> <span className="hist-title">Keyboard shortcuts</span>
<span className="mode-chip"><kbd>esc</kbd></span> <kbd>esc</kbd>
</div> </div>
<div className="help-list"> <div className="help-list">
{SHORTCUTS.map((s, i) => ( {SHORTCUTS.map((s, i) => (
@@ -392,6 +455,44 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
) )
} }
/**
* Scratch note for the project, stored as plain text in `.notes.txt`.
*
* The overlay only edits the text. Saving is the App's job, because the note
* must also be written when the window loses focus with the overlay shut.
* ⌘P (pass the note to the agent) is the App's job too — it owns the shortcut.
*/
export function NotesModal({ text, onChange, onClose }: {
text: string
onChange: (text: string) => void
onClose: () => void
}): React.ReactElement {
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
el.focus()
// Caret at the end, so you carry on writing instead of overtyping.
el.setSelectionRange(el.value.length, el.value.length)
}, [])
return (
<div className="scrim" onMouseDown={onClose}>
<div className="notes-modal" onMouseDown={(e) => e.stopPropagation()}>
<div className="pi">
{Icon.note({ style: { color: 'var(--fg-3)' } })}
<span className="hist-title">Note</span>
<span className="notes-file">.notes.txt</span>
<span className="notes-hint">{Icon.spark()} To agent <kbd>P</kbd></span>
<kbd>esc</kbd>
</div>
<textarea ref={ref} className="notes-input" spellCheck={false}
placeholder="Anything you want to keep next to this project…"
value={text} onChange={(e) => onChange(e.target.value)} />
</div>
</div>
)
}
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so /* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
* it owns the keyboard while open. */ * it owns the keyboard while open. */
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: { export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {

View File

@@ -3,9 +3,10 @@
* already consumed from the mock. When window.helder is absent (e.g. a plain * already consumed from the mock. When window.helder is absent (e.g. a plain
* browser preview) it falls back to the mock so the UI still renders. */ * browser preview) it falls back to the mock so the UI still renders. */
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import type { Change, Diff, FileNode, HelderConfig } from './types' import type { Change, Diff, FileNode, GitStatus, HelderConfig } from './types'
import { DEFAULT_CONFIG } from './types' import { DEFAULT_CONFIG, rowId } from './types'
import { makeDiff } from './diff' import { makeDiff } from './diff'
import { rlog } from './log'
import { PROJECT as MOCK } from './data' import { PROJECT as MOCK } from './data'
export interface RecentProject { path: string; name: string } export interface RecentProject { path: string; name: string }
@@ -16,8 +17,13 @@ export interface ProjectData {
branch: string branch: string
tree: FileNode | null tree: FileNode | null
files: Record<string, string> files: Record<string, string>
/** Git rows. One file can appear twice: staged and unstaged (see Change.id). */
changes: Change[] changes: Change[]
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
diffs: Record<string, Diff> diffs: Record<string, Diff>
/** Per row id: that row's own pair. Drives Diff and Split. */
rowDiffs: Record<string, Diff>
/** Paths that have a staged row. */
staged: Set<string> staged: Set<string>
config: HelderConfig config: HelderConfig
isRepo: boolean isRepo: boolean
@@ -60,6 +66,7 @@ export interface ProjectActions {
stageAll: () => void stageAll: () => void
unstageAll: () => void unstageAll: () => void
commit: (message: string) => Promise<number> commit: (message: string) => Promise<number>
push: () => Promise<{ ok: boolean; message: string }>
discard: (path: string) => void discard: (path: string) => void
ensureFile: (path: string) => void ensureFile: (path: string) => void
/** Force re-read a file from disk into the content index, returning the fresh /** Force re-read a file from disk into the content index, returning the fresh
@@ -71,18 +78,36 @@ export interface ProjectActions {
const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json'] const MOCK_STAGED = ['src/Service/PaymentService.php', 'config/app.json']
/** Preview mode has no index, so each mock file is one row. The staged set says
* which group it lands in. */
function mockChanges(staged: Set<string>): Change[] {
return MOCK.changes.map((c) => {
const s = staged.has(c.path)
return { ...c, staged: s, id: rowId(c.path, s) }
})
}
function mockRowDiffs(changes: Change[]): Record<string, Diff> {
const out: Record<string, Diff> = {}
for (const c of changes) out[c.id] = MOCK.diffs[c.path]
return out
}
function mockData(): ProjectData { function mockData(): ProjectData {
const staged = new Set(MOCK_STAGED)
const changes = mockChanges(staged)
return { return {
// non-null root so browser-preview shows the workbench, not the launcher // non-null root so browser-preview shows the workbench, not the launcher
name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch, name: MOCK.name, root: '/mock/' + MOCK.name, branch: MOCK.branch,
tree: MOCK.tree, files: MOCK.files, changes: MOCK.changes, diffs: MOCK.diffs, tree: MOCK.tree, files: MOCK.files, changes, diffs: MOCK.diffs,
staged: new Set(MOCK_STAGED), config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [], rowDiffs: mockRowDiffs(changes),
staged, config: DEFAULT_CONFIG, isRepo: true, ready: true, recents: [],
} }
} }
const emptyData: ProjectData = { const emptyData: ProjectData = {
name: 'Loading…', root: null, branch: '—', tree: null, files: {}, name: 'Loading…', root: null, branch: '—', tree: null, files: {},
changes: [], diffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [], changes: [], diffs: {}, rowDiffs: {}, staged: new Set(), config: DEFAULT_CONFIG, isRepo: false, ready: false, recents: [],
} }
const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
@@ -93,19 +118,32 @@ const Ctx = createContext<{ data: ProjectData; actions: ProjectActions }>({
/** Map a git.load() result into the git-derived slice of ProjectData. Shared by /** 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. */ * 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']>> type GitLoadResult = Awaited<ReturnType<NonNullable<typeof window.helder>['git']['load']>>
function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'staged' | 'isRepo'> { function deriveGit(git: GitLoadResult): Pick<ProjectData, 'branch' | 'changes' | 'diffs' | 'rowDiffs' | 'staged' | 'isRepo'> {
const changes: Change[] = [] const changes: Change[] = []
const rowDiffs: Record<string, Diff> = {}
const diffs: Record<string, Diff> = {} const diffs: Record<string, Diff> = {}
const staged = new Set<string>() 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) { if (git) {
for (const c of git.changes) { for (const c of git.changes) {
const d = makeDiff(c.status, c.original, c.updated) const d = makeDiff(c.status, c.original, c.updated)
diffs[c.path] = d const id = rowId(c.path, c.staged)
changes.push({ path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === 'D' }) 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) 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 })
} }
} }
return { branch: git ? git.branch : '—', changes, diffs, staged, isRepo: !!git } for (const [path, w] of whole) diffs[path] = makeDiff(w.status, w.head, w.disk)
return { branch: git ? git.branch : '—', changes, diffs, rowDiffs, staged, isRepo: !!git }
} }
export function useProject(): ProjectData { export function useProject(): ProjectData {
@@ -121,6 +159,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
const dataRef = useRef(data) const dataRef = useRef(data)
dataRef.current = data dataRef.current = data
const loadSeq = useRef(0) const loadSeq = useRef(0)
// Git has its OWN counter. It used to share loadSeq, which quietly broke ⌘R:
// the git poll (git.refreshInterval, 10s) and the git-column focus refresh
// both bump the counter, so any full reload still in flight — the tree walk is
// the slow part — saw seq !== loadSeq and bailed out completely. The tree then
// never updated while git kept looking fine. Two counters: a git-only read can
// no longer cancel a tree read, and each still settles on its newest result.
const gitSeq = useRef(0)
async function loadReal(): Promise<void> { async function loadReal(): Promise<void> {
if (!bridge) return if (!bridge) return
@@ -129,6 +174,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
// apply its result — otherwise a slow/transient mid-checkout read can // apply its result — otherwise a slow/transient mid-checkout read can
// resolve last and clobber the correct settled state. // resolve last and clobber the correct settled state.
const seq = ++loadSeq.current const seq = ++loadSeq.current
const gseq = ++gitSeq.current
const cur = await bridge.project.current() const cur = await bridge.project.current()
if (seq !== loadSeq.current) return if (seq !== loadSeq.current) return
// Bare launch (Spotlight / no project): flip ready immediately so the // Bare launch (Spotlight / no project): flip ready immediately so the
@@ -145,10 +191,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
]) ])
if (seq !== loadSeq.current) return if (seq !== loadSeq.current) return
applyTheme(theme) applyTheme(theme)
// 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) => ({ setData((d) => ({
name: cur.name, root: cur.root, name: cur.name, root: cur.root,
tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents, tree, files: d.root === cur.root ? d.files : {}, config, ready: true, recents: d.recents,
...deriveGit(git), ...(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 // The whole-repo content index is only a fallback (real viewing/search go
// through fs.read + ripgrep), and reading every file serially costs seconds. // through fs.read + ripgrep), and reading every file serially costs seconds.
@@ -164,13 +213,13 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
// re-read ONLY git status and patch the git fields, skipping the full // 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 // loadReal() that also re-walks the file tree + content index. This is the
// direct, immediate refresh those actions trigger — the .git watcher stays a // direct, immediate refresh those actions trigger — the .git watcher stays a
// backstop for git changes made by external tools. Shares loadSeq so a // backstop for git changes made by external tools. Uses gitSeq only, so it
// concurrent full reload still settles to the newest read. // never cancels an in-flight full reload (see the gitSeq note above).
async function loadGit(): Promise<void> { async function loadGit(): Promise<void> {
if (!bridge) return if (!bridge) return
const seq = ++loadSeq.current const seq = ++gitSeq.current
const git = await bridge.git.load() const git = await bridge.git.load()
if (seq !== loadSeq.current) return if (seq !== gitSeq.current) return
setData((d) => ({ ...d, ...deriveGit(git) })) setData((d) => ({ ...d, ...deriveGit(git) }))
} }
@@ -199,7 +248,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
if (!bridge) { if (!bridge) {
// ---- mock-mode actions (preview only) ---- // ---- mock-mode actions (preview only) ----
const setStaged = (fn: (s: Set<string>) => Set<string>): void => const setStaged = (fn: (s: Set<string>) => Set<string>): void =>
setData((d) => ({ ...d, staged: fn(new Set(d.staged)) })) setData((d) => {
const staged = fn(new Set(d.staged))
const changes = mockChanges(staged).filter((c) => d.changes.some((o) => o.path === c.path))
return { ...d, staged, changes, rowDiffs: mockRowDiffs(changes) }
})
return { return {
openFolder: () => {}, openFolder: () => {},
openProjectPath: () => {}, openProjectPath: () => {},
@@ -208,14 +261,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
refreshGit: () => {}, refreshGit: () => {},
stage: (p) => setStaged((s) => (s.add(p), s)), stage: (p) => setStaged((s) => (s.add(p), s)),
unstage: (p) => setStaged((s) => (s.delete(p), s)), unstage: (p) => setStaged((s) => (s.delete(p), s)),
stageAll: () => setData((d) => ({ ...d, staged: new Set(d.changes.map((c) => c.path)) })), stageAll: () => setStaged(() => new Set(dataRef.current.changes.map((c) => c.path))),
unstageAll: () => setData((d) => ({ ...d, staged: new Set() })), unstageAll: () => setStaged(() => new Set()),
commit: async (_msg) => { commit: async (_msg) => {
const cur = dataRef.current const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() })) setData((d) => ({ ...d, changes: d.changes.filter((c) => !c.staged), staged: new Set() }))
return n return n
}, },
push: async () => ({ ok: true, message: 'Pushed (preview)' }),
discard: (p) => setData((d) => ({ discard: (p) => setData((d) => ({
...d, ...d,
changes: d.changes.filter((c) => c.path !== p), changes: d.changes.filter((c) => c.path !== p),
@@ -240,8 +294,9 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
stage: (p) => after(bridge.git.stage([p])), stage: (p) => after(bridge.git.stage([p])),
unstage: (p) => after(bridge.git.unstage([p])), unstage: (p) => after(bridge.git.unstage([p])),
stageAll: () => { stageAll: () => {
const cur = dataRef.current // Every path with an unstaged row — including files that already have a
const unstaged = cur.changes.filter((c) => !cur.staged.has(c.path)).map((c) => c.path) // staged row and were edited again since.
const unstaged = [...new Set(dataRef.current.changes.filter((c) => !c.staged).map((c) => c.path))]
if (unstaged.length) after(bridge.git.stage(unstaged)) if (unstaged.length) after(bridge.git.stage(unstaged))
}, },
unstageAll: () => { unstageAll: () => {
@@ -250,24 +305,32 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
}, },
commit: async (msg) => { commit: async (msg) => {
const cur = dataRef.current const cur = dataRef.current
const n = cur.changes.filter((c) => cur.staged.has(c.path)).length const n = new Set(cur.changes.filter((c) => c.staged).map((c) => c.path)).size
await bridge.git.commit(msg) await bridge.git.commit(msg)
await loadGit() await loadGit()
return n return n
}, },
push: async () => {
const r = await bridge.git.push()
await loadGit()
return r
},
discard: (p) => after(bridge.git.discard([p])), discard: (p) => after(bridge.git.discard([p])),
ensureFile: (path) => { ensureFile: (path) => {
if (dataRef.current.files[path] != null) return if (dataRef.current.files[path] != null) return
bridge.fs.read(path).then((txt) => { bridge.fs.read(path).then((txt) => {
setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } })) setData((d) => (d.files[path] != null ? d : { ...d, files: { ...d.files, [path]: txt } }))
}).catch(() => {}) }).catch((e) => rlog.error('fs', 'prime read failed', e, { path }))
}, },
reloadFile: async (path) => { reloadFile: async (path) => {
try { try {
const txt = await bridge.fs.read(path) const txt = await bridge.fs.read(path)
setData((d) => ({ ...d, files: { ...d.files, [path]: txt } })) setData((d) => ({ ...d, files: { ...d.files, [path]: txt } }))
return txt return txt
} catch { } 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] ?? '' return dataRef.current.files[path] ?? ''
} }
}, },

View File

@@ -1,18 +1,18 @@
/* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */ /* ============ Helder — dark, charcoal-neutral (ported from design handoff) ============ */
:root { :root {
--bg-0:#16171a; /* editor surface (deepest) */ --bg-0:#2b2e34; /* editor surface (deepest) */
--bg-1:#1a1c1f; /* terminals */ --bg-1:#30343b; /* terminals */
--bg-2:#1f2226; /* sidebars */ --bg-2:#373c44; /* sidebars */
--bg-3:#23262b; /* headers / tabs strip */ --bg-3:#40454e; /* headers / tabs strip */
--hover:#2a2e34; --hover:#4a505a;
--active:#313742; --active:#535a66;
--sel:#2b323d; --sel:#49525f;
--border:#2a2d33; --border:#474c55;
--border-2:#34383f; --border-2:#535963;
--fg-0:#e6e8ea; --fg-0:#fbfcfd;
--fg-1:#b4bac2; --fg-1:#dde1e7;
--fg-2:#838a94; --fg-2:#b0b6bf;
--fg-3:#5d636c; --fg-3:#8f96a0;
--accent:#f19f3f; --accent:#f19f3f;
@@ -55,6 +55,12 @@ body {
#root { height:100vh; } #root { height:100vh; }
::selection { background:rgba(241,159,63,0.30); } ::selection { background:rgba(241,159,63,0.30); }
/* One key chip, used by every shortcut hint in the app — title bar, modal
headers, empty editor, context hints. Components may only add layout
(flex, min-width, alignment) or a colour that their own surface demands. */
kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3);
background:transparent; border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
/* scrollbars */ /* scrollbars */
::-webkit-scrollbar { width:11px; height:11px; } ::-webkit-scrollbar { width:11px; height:11px; }
::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; } ::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
@@ -106,27 +112,40 @@ body {
.tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; } .tb-crumb .tb-dirty { color:var(--mod); font-size:10px; margin-left:4px; }
.tb-spacer { flex:1; } .tb-spacer { flex:1; }
.tb-actions { display:flex; gap:6px; align-items:center; } .tb-actions { display:flex; gap:6px; align-items:center; }
/* Title-bar actions are borderless — the accent alone says "on", so no On/Off
badge is needed. Hover is the only other surface they get. */
.tb-btn { .tb-btn {
font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent; font-size:11.5px; color:var(--fg-2); background:transparent; border:0;
border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px; border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
} }
.tb-btn:hover { background:var(--hover); color:var(--fg-0); } .tb-btn:hover { background:var(--hover); color:var(--fg-0); }
.tb-btn kbd { font-family:var(--mono); font-size:11.5px; 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 { border-color:var(--border); } .tb-toggle.on kbd { color:var(--accent); border-color:var(--accent-line); }
.tb-toggle .tb-state { font-family:var(--mono); font-size:10px; border-radius:4px; padding:1px 5px; background:var(--bg-1); color:var(--fg-3); } .tb-toggle.on:hover { background:var(--accent-soft); }
.tb-toggle.on { color:var(--fg-1); border-color:var(--border-2); }
.tb-toggle.on .tb-state { background:var(--accent-soft); color:var(--accent); }
.workbench { flex:1; display:flex; min-height:0; } .workbench { flex:1; display:flex; min-height:0; }
.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); } /* --col-bg holds each column's resting background so the ⌘R flash below can
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). */ /* Editor (C) shares the side panels' background (--bg-2), matching B (and A). */
.col.editor-col { flex:1; min-width:240px; } .col.editor-col { flex:1; min-width:240px; }
.col.right-col { background:var(--bg-1); } .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 /* 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. */ tint as the side panels, so the file view stays in step with B in/out of focus. */
.col.panel-active { background:#22252a; } .col.panel-active { --col-bg:#22252a; background:var(--col-bg); }
.col.right-col.panel-active { background:#1e2024; } .col.right-col.panel-active { --col-bg:#1e2024; background:var(--col-bg); }
/* ⌘R refresh flash: A, B and C blink light grey and fade back. Two identical
animations (a/b) alternate so a second ⌘R replays it — a CSS animation only
restarts when the animation-name changes. */
.col.refresh-flash-a { animation:col-refresh-a 260ms ease-out; }
.col.refresh-flash-b { animation:col-refresh-b 260ms ease-out; }
@keyframes col-refresh-a { 0% { background:#3a4048; } 100% { background:var(--col-bg); } }
@keyframes col-refresh-b { 0% { background:#3a4048; } 100% { background:var(--col-bg); } }
@media (prefers-reduced-motion:reduce) {
.col.refresh-flash-a, .col.refresh-flash-b { animation-duration:180ms; animation-timing-function:steps(2); }
}
.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; } .splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; } .splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
@@ -149,6 +168,8 @@ body {
.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; } .commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
.commit-input:focus { border-color:var(--accent-line); } .commit-input:focus { border-color:var(--accent-line); }
.commit-input::placeholder { color:var(--fg-3); } .commit-input::placeholder { color:var(--fg-3); }
.push-btn { flex:0 0 auto; display:flex; align-items:center; justify-content:center; width:32px; min-height:32px; align-self:stretch; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-2); cursor:pointer; }
.push-btn:hover { background:var(--hover); border-color:var(--accent-line); color:var(--accent); }
.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; } .commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
.commit-btn:hover:not(:disabled) { background:#f6b35f; } .commit-btn:hover:not(:disabled) { background:#f6b35f; }
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; } .commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
@@ -209,7 +230,17 @@ body {
/* ============ editor ============ */ /* ============ editor ============ */
.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; } .editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px; } /* One grid column, minmax(max-content, 1fr). The track's base is the longest
line, so every row stretches to it and keeps painting its add/del background
all the way to the right edge. Plain block rows stop at the viewport, so a
changed line lost its colour the moment you scrolled right.
The 1fr max handles the other direction: when the file is narrower than the
pane the track grows to fill it. Do not flip this to minmax(100%, max-content)
— a track only grows past its base into free space, and a scrolled pane has
none, so it would pin every row to the viewport width again.
align-content:start stops a short file from stretching rows vertically. */
.editor { flex:1; overflow:auto; font-family:var(--code-font); font-size:var(--code-size); line-height:20px; padding:6px 0 40px;
display:grid; grid-template-columns:minmax(max-content, 1fr); align-content:start; }
.ln-row { display:flex; align-items:flex-start; min-height:20px; } .ln-row { display:flex; align-items:flex-start; min-height:20px; }
.ln-row.cursor { background:rgba(255,255,255,0.035); } .ln-row.cursor { background:rgba(255,255,255,0.035); }
.ln-row.add { background:var(--add-bg); } .ln-row.add { background:var(--add-bg); }
@@ -221,11 +252,13 @@ body {
.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); } .ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
.ln-row.add .ln-sign { color:var(--add); } .ln-row.add .ln-sign { color:var(--add); }
.ln-row.del .ln-sign { color:var(--del); } .ln-row.del .ln-sign { color:var(--del); }
.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; } /* flex-basis auto (not 0) so the line's real width counts towards the row's
intrinsic size. With basis 0 the grid track above collapses to the viewport
and the add/del background stops at the fold again. */
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 6px; min-width:0; }
.editor.diff .ln-code { padding-left:6px; } .editor.diff .ln-code { padding-left:6px; }
.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; } .empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
.empty-ed .big { font-size:13px; } .empty-ed .big { font-size:13px; }
.empty-ed kbd { font-family:var(--mono); font-size:12px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; } .empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; } .empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
@@ -243,6 +276,26 @@ body {
.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); } .diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
.diff-bar .seg .split-btn svg { opacity:.85; } .diff-bar .seg .split-btn svg { opacity:.85; }
.diff-bar .db-lang { font-family:var(--mono); font-size:10.5px; color:var(--fg-3); letter-spacing:.02em; } .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) */ /* rendered-markdown preview (Preview view option) */
.md-view { flex:1; overflow:auto; padding:8px 0 48px; } .md-view { flex:1; overflow:auto; padding:8px 0 48px; }
@@ -262,6 +315,11 @@ body {
.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 { 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 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; } .md-body strong { color:var(--fg-0); font-weight:600; }
/* tables scroll on their own so a wide one never widens the whole preview */
.md-body table.md-table { display:block; width:max-content; max-width:100%; overflow-x:auto; border-collapse:collapse; margin:.9em 0; font-size:.94em; }
.md-body table.md-table th, .md-body table.md-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; vertical-align:top; }
.md-body table.md-table th { background:var(--bg-2); color:var(--fg-0); font-weight:600; white-space:nowrap; }
.md-body table.md-table tbody tr:nth-child(even) { background:var(--bg-1); }
/* gutter change bars (Original / Updated / Split) */ /* gutter change bars (Original / Updated / Split) */
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); } .ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
@@ -275,7 +333,6 @@ body {
.split-head .git-stat { font-size:11px; } .split-head .git-stat { font-size:11px; }
.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; } .split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
.split-exit:hover { background:var(--hover); color:var(--fg-0); } .split-exit:hover { background:var(--hover); color:var(--fg-0); }
.split-exit kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
.split-body { flex:1; display:flex; min-height:0; } .split-body { flex:1; display:flex; min-height:0; }
.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; } .split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
.split-pane.left { border-right:1px solid var(--border-2); } .split-pane.left { border-right:1px solid var(--border-2); }
@@ -347,11 +404,11 @@ body {
.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; } .pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
/* combined search modal (content + files) */ /* combined search modal (content + files) */
.search-modal { width:1040px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; } .search-modal { width:min(1680px, 92vw); max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.search-cols { display:flex; min-height:0; } .search-cols { display:flex; min-height:0; }
.sc-infile { flex:0 0 290px; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; background:rgba(0,0,0,0.18); } .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:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; } .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 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); } .sc-right { flex:0 0 20%; min-width:0; max-height:min(72vh, 720px); overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; } .sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
.sc-right .sc-head { background:#1e2024; } .sc-right .sc-head { background:#1e2024; }
.sc-infile .sc-head { background:#1c1e22; text-transform:none; letter-spacing:0; } .sc-infile .sc-head { background:#1c1e22; text-transform:none; letter-spacing:0; }
@@ -373,7 +430,6 @@ body {
.history-modal .pi svg { flex:0 0 auto; } .history-modal .pi svg { flex:0 0 auto; }
.history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; } .history-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; } .history-modal .mode-chip { flex:0 0 auto; white-space:nowrap; font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); display:flex; align-items:center; gap:4px; }
.history-modal .mode-chip kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; }
.hist-list { max-height:460px; overflow:auto; padding:5px 0; } .hist-list { max-height:460px; overflow:auto; padding:5px 0; }
.hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; } .hist-row { display:flex; align-items:center; gap:9px; padding:6px 13px; cursor:pointer; }
.hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); } .hist-row.sel { background:var(--accent-dim, rgba(241,159,63,0.14)); box-shadow:inset 2px 0 0 var(--accent); }
@@ -384,7 +440,7 @@ body {
.hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .hist-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* content search */ /* content search */
.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; } .search-results { max-height:min(68vh, 680px); overflow:auto; padding:4px 0 8px; }
.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; } .sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
.sr-file:hover { color:var(--fg-0); } .sr-file:hover { color:var(--fg-0); }
.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; } .sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
@@ -409,7 +465,6 @@ body {
.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; } .pass-preview code.multiline { white-space:pre-wrap; text-overflow:clip; max-height:132px; overflow:auto; word-break:break-word; }
.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); } .pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
.pass-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
/* terminal multi-line input */ /* terminal multi-line input */
.term-input { align-items:flex-start; } .term-input { align-items:flex-start; }
@@ -446,7 +501,9 @@ body {
.ce-gutter { padding-top:6px; will-change:transform; } .ce-gutter { padding-top:6px; will-change:transform; }
.ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; } .ce-gutter div { height:20px; line-height:20px; text-align:right; padding-right:14px; color:var(--fg-3); font-family:var(--code-font); font-size:12px; user-select:none; }
.ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; } .ce-scroll { flex:1; min-width:0; overflow:auto; position:relative; }
.ce-inner { position:relative; width:max-content; min-width:100%; } /* min-height keeps the inset:0 textarea filling the pane on short/empty files,
so a click anywhere in the blank area below the last line still lands. */
.ce-inner { position:relative; width:max-content; min-width:100%; min-height:100%; }
.ce-pre, .ce-ta { .ce-pre, .ce-ta {
margin:0; padding:6px 16px 40px 6px; border:0; margin:0; padding:6px 16px 40px 6px; border:0;
font-family:var(--code-font); font-size:var(--code-size); line-height:20px; font-family:var(--code-font); font-size:var(--code-size); line-height:20px;
@@ -469,6 +526,8 @@ body {
decorative dots are hidden and the bar is made draggable. Interactive controls decorative dots are hidden and the bar is made draggable. Interactive controls
opt back out of the drag region. */ opt back out of the drag region. */
.titlebar { -webkit-app-region: drag; padding-left: 82px; } .titlebar { -webkit-app-region: drag; padding-left: 82px; }
/* Fullscreen: no traffic lights, so the project name moves back to the edge. */
.titlebar.fullscreen { padding-left: 12px; }
.titlebar .traffic { display: none; } .titlebar .traffic { display: none; }
.titlebar button, .titlebar button,
.titlebar input, .titlebar input,
@@ -497,21 +556,33 @@ body {
.lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; } .lp-txt { min-width:0; display:flex; flex-direction:column; line-height:1.3; flex:1; }
.lp-name { font-size:13px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .lp-name { font-size:13px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; } .lp-path { font-size:11px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; direction:rtl; text-align:left; }
.lp-row kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; flex:0 0 auto; }
.lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); } .lp-foot { padding:10px 20px; border-top:1px solid var(--border); font-size:10.5px; color:var(--fg-3); }
.lp-foot kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
/* ============ keyboard-shortcuts (help) modal ============ */ /* ============ keyboard-shortcuts (help) modal ============ */
/* Project note (.notes.txt). Capped at 1000px so the text stays readable on a
wide screen; the height fills nearly the whole window, with a floor for small
ones, because a note is usually long. */
.notes-modal { width:1000px; max-width:92vw; height:calc(100vh - 116px); min-height:260px;
background:#212429; border:1px solid var(--border-2); border-radius:11px;
box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.notes-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.notes-modal .pi svg { flex:0 0 auto; }
.notes-modal .hist-title { color:var(--fg-1); font-size:14px; }
.notes-modal .notes-file { flex:1; min-width:0; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
.notes-modal .notes-hint { flex:0 0 auto; display:flex; align-items:center; gap:6px; font-size:11.5px; color:var(--fg-2); }
.notes-input { flex:1; min-height:0; width:100%; resize:none; background:transparent; border:0; outline:0;
padding:14px 16px; color:var(--fg-1); font-family:var(--code-font); font-size:var(--code-size); line-height:20px; }
.notes-input::placeholder { color:var(--fg-3); }
.help-modal { width:520px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; } .help-modal { width:520px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
.help-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); } .help-modal .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
.help-modal .pi svg { flex:0 0 auto; } .help-modal .pi svg { flex:0 0 auto; }
.help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; } .help-modal .hist-title { flex:1; min-width:0; color:var(--fg-1); font-size:14px; }
.help-modal .mode-chip { flex:0 0 auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
.help-list { max-height:62vh; overflow:auto; padding:8px 6px; } .help-list { max-height:62vh; overflow:auto; padding:8px 6px; }
.help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; } .help-row { display:flex; align-items:center; gap:14px; padding:6px 12px; border-radius:7px; }
.help-row:hover { background:var(--hover); } .help-row:hover { background:var(--hover); }
.help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; } .help-keys { flex:0 0 96px; display:flex; gap:4px; justify-content:flex-end; }
.help-keys kbd { font-family:var(--mono); font-size:12px; color:var(--fg-1); background:var(--bg-1); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; min-width:20px; text-align:center; } .help-keys kbd { min-width:20px; text-align:center; }
.help-label { font-size:12.5px; color:var(--fg-2); } .help-label { font-size:12.5px; color:var(--fg-2); }
/* title-bar icon-only button (help ?) */ /* title-bar icon-only button (help ?) */
@@ -524,7 +595,6 @@ body {
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; } .cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; } .cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
.cf-btn:hover { background:var(--hover); color:var(--fg-0); } .cf-btn:hover { background:var(--hover); color:var(--fg-0); }
.cf-btn kbd { font-family:var(--mono); font-size:11.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; } .cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
.cf-yes:hover { background:#f6b35f; color:#201608; } .cf-yes:hover { background:#f6b35f; color:#201608; }
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); } .cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
@@ -533,7 +603,7 @@ body {
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); } .cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
/* search: active result column + file-name selection */ /* search: active result column + file-name selection */
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; } .sc-head .col-kbd { margin-left:auto; opacity:.55; }
.sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); } .sc-left.active .sc-head, .sc-right.active .sc-head, .sc-infile.active .sc-head { color:var(--accent); }
.sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; } .sc-left.active .sc-head .col-kbd, .sc-right.active .sc-head .col-kbd, .sc-infile.active .sc-head .col-kbd { color:var(--accent); border-color:var(--accent-line); opacity:1; }
.sc-infile.active .sc-head .scf-name { color:var(--accent); } .sc-infile.active .sc-head .scf-name { color:var(--accent); }

View File

@@ -23,7 +23,8 @@ const THEME = {
foreground: '#e6e8ea', foreground: '#e6e8ea',
cursor: '#4d8dff', cursor: '#4d8dff',
cursorAccent: '#1a1c1f', cursorAccent: '#1a1c1f',
selectionBackground: 'rgba(77,141,255,0.32)', selectionBackground: 'rgba(77,141,255,0.55)',
selectionInactiveBackground: 'rgba(77,141,255,0.40)',
black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c', black: '#16171a', red: '#e0696a', green: '#5cbd6b', yellow: '#d8a85c',
blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2', blue: '#4d8dff', magenta: '#c98bdb', cyan: '#6ec0c0', white: '#b4bac2',
brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c', brightBlack: '#5d636c', brightRed: '#e0696a', brightGreen: '#5cbd6b', brightYellow: '#d8a85c',

View File

@@ -45,6 +45,21 @@ export interface Change {
add: number add: number
del: number del: number
deleted: boolean deleted: boolean
/** True when this row is the staged half of the file. A file that is staged
* and then edited again produces two rows, one in each group. */
staged: boolean
/** Row identity. Path alone is no longer unique — see `staged`. */
id: string
}
/** Which half of a file's changes a diff view shows. `staged` is HEAD vs the
* index, `unstaged` is the index vs disk. Absent means the whole file: HEAD vs
* disk, which is what Original and Actual always show. */
export type DiffSide = 'staged' | 'unstaged'
/** Row id for a git change. Keeps the two halves of one file apart. */
export function rowId(path: string, staged: boolean): string {
return (staged ? 's:' : 'w:') + path
} }
export interface Project { export interface Project {
@@ -71,7 +86,7 @@ export type DiffMode = 'original' | 'updated' | 'diff'
export interface HelderConfig { export interface HelderConfig {
ai: { command: string; autoLaunch: boolean } ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number } editor: { autoSave: boolean; tabSize: number }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode } git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean } files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null } terminal: { shell: string | null }
session: { restoreOnLaunch: boolean } session: { restoreOnLaunch: boolean }
@@ -80,7 +95,7 @@ export interface HelderConfig {
export const DEFAULT_CONFIG: HelderConfig = { export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true }, ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 }, editor: { autoSave: false, tabSize: 4 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff' }, git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true }, files: { exclude: [], followGitignore: true },
terminal: { shell: null }, terminal: { shell: null },
session: { restoreOnLaunch: true }, session: { restoreOnLaunch: true },

83
test/editor-tab.test.tsx Normal file
View File

@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
})
afterEach(() => { cleanup(); localStorage.clear() })
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
}
// Open a changed file and switch to the writable buffer.
async function openEditor(): Promise<HTMLTextAreaElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
const row = await waitFor(() => {
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
if (!r) throw new Error('git not ready')
return r
})
fireEvent.click(row)
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
fireEvent.click(find(c, '.seg button', 'Actual')!)
return await waitFor(() => {
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
if (!ta) throw new Error('no buffer')
return ta
})
}
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
}
describe('Tab in the code editor', () => {
it('inserts four spaces at the caret instead of moving focus', async () => {
const ta = await openEditor()
const before = ta.value
ta.setSelectionRange(0, 0)
// fireEvent returns false when a handler called preventDefault, which is
// what stops the browser tabbing focus over to the agent column.
expect(tab(ta)).toBe(false)
await waitFor(() => expect(ta.value).toBe(' ' + before))
})
it('indents every line a multi-line selection touches', async () => {
const ta = await openEditor()
const lines = ta.value.split('\n')
// Select from inside line 1 to inside line 2.
ta.setSelectionRange(1, lines[0].length + 2)
tab(ta)
await waitFor(() => {
const now = ta.value.split('\n')
expect(now[0]).toBe(' ' + lines[0])
expect(now[1]).toBe(' ' + lines[1])
expect(now[2]).toBe(lines[2])
})
})
it('Shift+Tab outdents the current line', async () => {
const ta = await openEditor()
ta.setSelectionRange(0, 0)
tab(ta)
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
ta.setSelectionRange(6, 6)
expect(tab(ta, true)).toBe(false)
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
})
})

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

@@ -0,0 +1,168 @@
// @vitest-environment jsdom
//
// A file can be staged and then edited again. Git calls that "MM": two rows,
// one per group. These tests drive the renderer with such a payload and check
// that Diff follows the row you clicked, while Original and Actual do not.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
const HEAD = 'a\nb\nc\n'
const INDEX = 'a\nSTAGED\nc\n'
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
/** Minimal preload bridge: enough for the store to boot with real git rows. */
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
readDir: async () => [],
files: async () => ({ 'demo.txt': DISK }),
read: async () => DISK,
imageDataUrl: async () => '',
write: async () => {},
delete: async () => {},
create: async () => {},
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
// Exactly what git-service now returns for porcelain "MM".
load: async () => ({
branch: 'main',
changes: [
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
}),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onProjectChanged: off,
onConfigChanged: off,
onRefresh: off,
}
}
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(stubBridge)
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
})
/** Row text of the view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
const head = heads.find((h) => h.textContent?.startsWith(label))!
const rows: HTMLElement[] = []
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
}
return rows
}
async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => {
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
})
return c
}
describe('a file that is staged and then edited again', () => {
it('shows up in both groups', async () => {
const c = await boot()
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
})
it('Diff on the staged row compares HEAD with the staged copy', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('b')
expect(text).toContain('STAGED')
// The later edit is not part of what is staged, so it must not show here.
expect(text).not.toContain('AFTER-STAGING')
})
it('Diff on the unstaged row compares the staged copy with disk', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('AFTER-STAGING')
// 'b' was already replaced before staging, so this half must not mention it.
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
})
it('labels which pair the diff is comparing', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
})
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
const c = await boot()
for (const label of ['Staged Changes', 'Changes'] as const) {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
fireEvent.click(original)
await waitFor(() => expect(viewText(c)).toContain('b'))
expect(viewText(c)).not.toContain('AFTER-STAGING')
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
fireEvent.click(actual)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
}
})
it('only the row you opened is highlighted', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
})
})

View File

@@ -9,17 +9,33 @@ import { classify, discard, load, stage } from '../src/main/git-service'
describe('classify', () => { describe('classify', () => {
it('reads the index code as staged, working code as unstaged', () => { it('reads the index code as staged, working code as unstaged', () => {
expect(classify('M', ' ')).toEqual({ letter: 'M', staged: true }) expect(classify('M', ' ')).toEqual([{ letter: 'M', staged: true }])
expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false }) expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }])
expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true }) expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }])
expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true }) expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }])
expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true }) expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }])
}) })
it('treats untracked as a new (A) unstaged file', () => { it('treats untracked as a new (A) unstaged file', () => {
expect(classify('?', '?')).toEqual({ letter: 'A', staged: false }) expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }])
}) })
it('maps unmerged (U) to modified', () => { it('splits a staged-then-edited file into two rows', () => {
expect(classify('U', 'U').letter).toBe('M') expect(classify('M', 'M')).toEqual([
{ letter: 'M', staged: true },
{ letter: 'M', staged: false },
])
expect(classify('A', 'M')).toEqual([
{ letter: 'A', staged: true },
{ letter: 'M', staged: false },
])
expect(classify('M', 'D')).toEqual([
{ letter: 'M', staged: true },
{ letter: 'D', staged: false },
])
})
it('keeps a merge conflict as one row', () => {
expect(classify('U', 'U')).toEqual([{ letter: 'M', staged: true }])
expect(classify('A', 'A')).toEqual([{ letter: 'M', staged: true }])
expect(classify('D', 'D')).toEqual([{ letter: 'M', staged: true }])
}) })
}) })
@@ -77,6 +93,51 @@ describe('load (integration against a temp repo)', () => {
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true) expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
}) })
it('shows a staged-then-edited file in both groups, each with its own pair', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n')
await stage(dir, ['a.txt'])
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n4\n')
const res = await load(dir)
const rows = res!.changes.filter((c) => c.path === 'a.txt')
expect(rows).toHaveLength(2)
// Staged row: HEAD -> index. It must NOT include the newer edit.
const s = rows.find((c) => c.staged)!
expect(s.original).toBe('1\n2\n3\n')
expect(s.updated).toBe('1\nSTAGED\n3\n')
// Unstaged row: index -> disk. Only the newer edit.
const w = rows.find((c) => !c.staged)!
expect(w.original).toBe('1\nSTAGED\n3\n')
expect(w.updated).toBe('1\nSTAGED\n3\n4\n')
})
it('gives a staged-new file that was edited again both rows', async () => {
dir = await repo()
await writeFile(join(dir, 'fresh.txt'), 'one\n')
await stage(dir, ['fresh.txt'])
await writeFile(join(dir, 'fresh.txt'), 'one\ntwo\n')
const res = await load(dir)
const rows = res!.changes.filter((c) => c.path === 'fresh.txt')
expect(rows.map((r) => [r.status, r.staged])).toEqual([['A', true], ['M', false]])
expect(rows[0].original).toBe('')
expect(rows[0].updated).toBe('one\n')
expect(rows[1].original).toBe('one\n')
expect(rows[1].updated).toBe('one\ntwo\n')
})
it('keeps one row when a file is only staged', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
await stage(dir, ['a.txt'])
const rows = (await load(dir))!.changes.filter((c) => c.path === 'a.txt')
expect(rows).toHaveLength(1)
expect(rows[0].staged).toBe(true)
expect(rows[0].original).toBe('1\n2\n3\n')
expect(rows[0].updated).toBe('1\n2\n3\n4\n')
})
it('discard reverts a modified tracked file to HEAD', async () => { it('discard reverts a modified tracked file to HEAD', async () => {
dir = await repo() dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n') await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')

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

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

View File

@@ -41,4 +41,33 @@ describe('renderMarkdown', () => {
expect(renderMarkdown('- a\n- b')).toContain('<ul><li>a</li><li>b</li></ul>') 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>') expect(renderMarkdown('1. a\n2. b')).toContain('<ol><li>a</li><li>b</li></ol>')
}) })
it('renders a GFM table with header, body and inline markup', () => {
const html = renderMarkdown('| a | b |\n|---|---|\n| 1 | `x` |\n| 2 | **y** |')
expect(html).toContain('<table class="md-table">')
expect(html).toContain('<thead><tr><th>a</th><th>b</th></tr></thead>')
expect(html).toContain('<td>1</td><td><code>x</code></td>')
expect(html).toContain('<strong>y</strong>')
})
it('applies column alignment from the delimiter row', () => {
const html = renderMarkdown('| l | c | r |\n| :-- | :-: | --: |\n| 1 | 2 | 3 |')
expect(html).toContain('<th style="text-align:left">l</th>')
expect(html).toContain('<th style="text-align:center">c</th>')
expect(html).toContain('<th style="text-align:right">r</th>')
expect(html).toContain('<td style="text-align:center">2</td>')
})
it('handles escaped pipes, ragged rows and pipe-less prose after the table', () => {
const html = renderMarkdown('| a | b |\n|---|---|\n| x \\| y | 2 |\n| short |\n\nAfter.')
expect(html).toContain('<td>x | y</td>')
expect(html).toContain('<td>short</td><td></td>')
expect(html).toContain('<p>After.</p>')
})
it('leaves a pipe-bearing paragraph alone when no delimiter row follows', () => {
const html = renderMarkdown('a | b\nnot a table')
expect(html).not.toContain('<table')
expect(html).toContain('<p>a | b not a table</p>')
})
}) })

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

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

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

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