Compare commits
6 Commits
73bfd2b86a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e126182ae6 | |||
| daf8945da7 | |||
| 03e16d49a1 | |||
| d3bcdb74c2 | |||
| 6c3a021bb9 | |||
| 43131915c0 |
@@ -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": [],
|
||||||
|
|||||||
14
CLAUDE.md
14
CLAUDE.md
@@ -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
44
build/adhoc-sign.cjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Ad-hoc sign the macOS bundle after electron-builder packs it.
|
||||||
|
//
|
||||||
|
// Why this exists: `mac.identity: null` tells electron-builder to skip signing
|
||||||
|
// altogether. The .app then keeps only the linker signature that Apple put on
|
||||||
|
// the prebuilt Electron binary. That signature says `Identifier=Electron`,
|
||||||
|
// seals no resources, and does not bind our Info.plist. macOS reads a bundle
|
||||||
|
// like that as tampered-with and shows "Malware Blocked and Moved to Trash".
|
||||||
|
//
|
||||||
|
// A real ad-hoc signature over the whole bundle fixes it. The app stays
|
||||||
|
// unsigned in the Developer ID sense (no notarization, so a *downloaded* copy
|
||||||
|
// still needs the quarantine flag cleared), but it is no longer flagged as
|
||||||
|
// malware and runs fine locally.
|
||||||
|
//
|
||||||
|
// Replace this with a Developer ID identity + notarization when the app ships.
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
exports.default = async function adhocSign(context) {
|
||||||
|
if (context.electronPlatformName !== 'darwin') return
|
||||||
|
|
||||||
|
const appName = context.packager.appInfo.productFilename
|
||||||
|
const appPath = path.join(context.appOutDir, `${appName}.app`)
|
||||||
|
const entitlements = path.join(__dirname, 'entitlements.mac.plist')
|
||||||
|
|
||||||
|
console.log(` • ad-hoc signing ${appPath}`)
|
||||||
|
execFileSync(
|
||||||
|
'codesign',
|
||||||
|
[
|
||||||
|
'--force',
|
||||||
|
'--deep',
|
||||||
|
'--sign',
|
||||||
|
'-',
|
||||||
|
'--options',
|
||||||
|
'runtime',
|
||||||
|
'--entitlements',
|
||||||
|
entitlements,
|
||||||
|
appPath
|
||||||
|
],
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fail the build rather than ship a bundle macOS will quarantine again.
|
||||||
|
execFileSync('codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' })
|
||||||
|
}
|
||||||
17
build/entitlements.mac.plist
Normal file
17
build/entitlements.mac.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- V8 compiles JavaScript at runtime. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Electron sets dyld vars when it spawns its own helpers. -->
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
<!-- node-pty is a native addon signed with a different (ad-hoc) identity. -->
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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: '^_' }],
|
||||||
|
|||||||
@@ -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
188
src/main/diagnostics.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { app, crashReporter, dialog, shell, BrowserWindow, type WebContents } from 'electron'
|
||||||
|
import { formatErr, getLogDir, getLogPath, initLogger, log, logger } from './logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that turns a silent death into a log line. Wires the process-,
|
||||||
|
* app- and window-level failure hooks Electron gives us, none of which were
|
||||||
|
* connected before — which is why crashes left no trace.
|
||||||
|
*
|
||||||
|
* The hooks, and the crash each one actually catches:
|
||||||
|
* uncaughtException / unhandledRejection → a throw in OUR main-process code
|
||||||
|
* render-process-gone → the renderer died (OOM, segfault):
|
||||||
|
* the classic "window went blank//white"
|
||||||
|
* child-process-gone → GPU / utility process died
|
||||||
|
* preload-error → preload threw: `window.helder` is
|
||||||
|
* undefined and the app silently falls
|
||||||
|
* back to MOCK DATA (see CLAUDE.md)
|
||||||
|
* unresponsive → main thread wedged (the beachball)
|
||||||
|
* crashReporter minidumps → NATIVE crashes (node-pty is native,
|
||||||
|
* and a segfault there takes the whole
|
||||||
|
* process down with no JS hook at all)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let fatalDialogOpen = false
|
||||||
|
|
||||||
|
/** Call FIRST, before app.whenReady() — crashReporter must start early to catch
|
||||||
|
* native crashes, and the log file should exist before anything can fail. */
|
||||||
|
export function initDiagnostics(isDev: boolean): void {
|
||||||
|
// app.getPath('logs') is ~/Library/Logs/<name> on macOS, so the name must be
|
||||||
|
// set before we ask for the path or the folder is called "Electron".
|
||||||
|
app.setName('Helder')
|
||||||
|
|
||||||
|
initLogger({ dir: app.getPath('logs'), mirror: isDev })
|
||||||
|
|
||||||
|
// Native minidumps for crashes no JS handler can see. Local only — nothing is
|
||||||
|
// uploaded anywhere (there is no server, and this is a personal tool).
|
||||||
|
try {
|
||||||
|
crashReporter.start({ productName: 'Helder', companyName: 'Helder', uploadToServer: false })
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('crash', 'crashReporter failed to start', { err: formatErr(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('session', 'starting', {
|
||||||
|
version: app.getVersion(),
|
||||||
|
electron: process.versions.electron,
|
||||||
|
chrome: process.versions.chrome,
|
||||||
|
node: process.versions.node,
|
||||||
|
platform: `${process.platform} ${process.arch}`,
|
||||||
|
packaged: app.isPackaged,
|
||||||
|
dev: isDev,
|
||||||
|
crashDumps: app.getPath('crashDumps'),
|
||||||
|
argv: process.argv.slice(1),
|
||||||
|
project: process.env.HELDER_PROJECT ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
installProcessHooks()
|
||||||
|
installAppHooks()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installProcessHooks(): void {
|
||||||
|
process.on('uncaughtException', (err, origin) => {
|
||||||
|
logger.error('fatal', `uncaughtException (${origin})`, err)
|
||||||
|
showFatal(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
// Not fatal in itself, but it's how a forgotten `await` on a failing IPC
|
||||||
|
// handler shows up — and the stack here is the only place the cause exists.
|
||||||
|
logger.error('fatal', 'unhandledRejection', reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.on('warning', (w) => {
|
||||||
|
// Surfaces the "MaxListenersExceeded" / deprecation warnings that precede
|
||||||
|
// a leak-driven crash.
|
||||||
|
logger.warn('node', w.name, { message: w.message, stack: w.stack })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => logger.info('session', 'quitting'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function installAppHooks(): void {
|
||||||
|
// THE renderer-crash hook. `reason` is the useful bit: 'crashed', 'oom',
|
||||||
|
// 'killed', 'launch-failed'.
|
||||||
|
app.on('render-process-gone', (_e, contents, details) => {
|
||||||
|
logger.error('renderer', `render process gone: ${details.reason}`, undefined, {
|
||||||
|
exitCode: details.exitCode,
|
||||||
|
reason: details.reason,
|
||||||
|
url: safeUrl(contents),
|
||||||
|
})
|
||||||
|
if (details.reason !== 'clean-exit') {
|
||||||
|
showFatal(new Error(`The window crashed (${details.reason}, exit ${details.exitCode}). See the log for details.`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('child-process-gone', (_e, details) => {
|
||||||
|
logger.error('child', `${details.type} process gone: ${details.reason}`, undefined, {
|
||||||
|
exitCode: details.exitCode,
|
||||||
|
serviceName: details.serviceName,
|
||||||
|
name: details.name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// A preload failure is silent-by-design in Electron and the single nastiest
|
||||||
|
// failure mode this app has: no window.helder → the renderer quietly serves
|
||||||
|
// mock data and "terminal not available", as if nothing were wrong.
|
||||||
|
app.on('web-contents-created', (_e, contents) => {
|
||||||
|
contents.on('preload-error', (_ev, preloadPath, error) => {
|
||||||
|
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
|
||||||
|
})
|
||||||
|
contents.on('console-message', (...a: unknown[]) => {
|
||||||
|
// Electron ≥36 passes a single event object; older versions pass
|
||||||
|
// (event, level, message, line, sourceId). Support both so a version bump
|
||||||
|
// doesn't quietly stop capturing renderer console output.
|
||||||
|
const d = normaliseConsoleMessage(a)
|
||||||
|
if (!d || d.level < 2) return // warnings + errors only; skip log/info noise
|
||||||
|
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Electron changed the console-message signature in v36; accept both shapes. */
|
||||||
|
export function normaliseConsoleMessage(args: unknown[]): { level: number; message: string; source: string; line: number } | null {
|
||||||
|
const first = args[0] as Record<string, unknown> | undefined
|
||||||
|
if (first && typeof first === 'object' && 'message' in first && 'level' in first) {
|
||||||
|
const lvl = first.level
|
||||||
|
const asNum = typeof lvl === 'string' ? { debug: 0, info: 1, verbose: 1, warning: 2, error: 3 }[lvl] ?? 1 : Number(lvl)
|
||||||
|
return {
|
||||||
|
level: asNum,
|
||||||
|
message: String(first.message),
|
||||||
|
source: String(first.sourceId ?? ''),
|
||||||
|
line: Number(first.lineNumber ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (args.length >= 3 && typeof args[1] === 'number') {
|
||||||
|
return { level: args[1] as number, message: String(args[2]), source: String(args[4] ?? ''), line: Number(args[3] ?? 0) }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUrl(contents: WebContents | null): string {
|
||||||
|
try { return contents?.getURL() ?? '' } catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watch for the beachball: log it (with a stack-free note) rather than let the
|
||||||
|
* user guess whether the app is hung or just slow. */
|
||||||
|
export function watchWindow(win: BrowserWindow): void {
|
||||||
|
win.on('unresponsive', () => logger.warn('window', 'became unresponsive (main thread blocked)'))
|
||||||
|
win.on('responsive', () => logger.info('window', 'responsive again'))
|
||||||
|
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||||
|
logger.error('window', 'did-fail-load', undefined, { code, desc, url })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the user something died, and put the log one click away — a crash the
|
||||||
|
* user can't report is a crash we can't fix. Guarded so a crash loop doesn't
|
||||||
|
* stack a hundred dialogs.
|
||||||
|
*/
|
||||||
|
function showFatal(err: unknown): void {
|
||||||
|
if (fatalDialogOpen) return
|
||||||
|
fatalDialogOpen = true
|
||||||
|
const { message } = formatErr(err)
|
||||||
|
const logPath = getLogPath()
|
||||||
|
Promise.resolve(dialog.showMessageBox({
|
||||||
|
type: 'error',
|
||||||
|
buttons: logPath ? ['Open Log', 'Ignore'] : ['Ignore'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: logPath ? 1 : 0,
|
||||||
|
message: 'Helder hit an error',
|
||||||
|
detail: `${message}\n\n${logPath ? `Logged to ${logPath}` : ''}`,
|
||||||
|
})).then(({ response }) => {
|
||||||
|
if (logPath && response === 0) openLog()
|
||||||
|
}).catch(() => { /* dialog can fail pre-ready; the log line is what matters */ })
|
||||||
|
.finally(() => { fatalDialogOpen = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the log in the default text editor. */
|
||||||
|
export function openLog(): void {
|
||||||
|
const p = getLogPath()
|
||||||
|
if (p) shell.openPath(p).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveal the log folder (all rotated files + siblings) in Finder. */
|
||||||
|
export function revealLog(): void {
|
||||||
|
const p = getLogPath()
|
||||||
|
if (p) shell.showItemInFolder(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getLogDir, getLogPath }
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
169
src/main/logger.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's one log sink: a plain-text file under the OS log dir, written
|
||||||
|
* SYNCHRONOUSLY so a line survives the process dying moments later.
|
||||||
|
*
|
||||||
|
* Why a file at all: `console.*` goes nowhere in real use. Helder runs one
|
||||||
|
* process per project window, and every window past the first is spawned by
|
||||||
|
* `spawnInstance()` with `stdio: 'ignore'` — its output is discarded. Launched
|
||||||
|
* from Finder there's no terminal attached either. Before this, a crash left
|
||||||
|
* literally no trace; that's what made "it crashed sometimes" undebuggable.
|
||||||
|
*
|
||||||
|
* This module deliberately does NOT import electron, so it stays unit-testable.
|
||||||
|
* `initLogger()` is handed the directory by the caller (see diagnostics.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
const LEVELS: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||||
|
|
||||||
|
/** Rotate at 2 MB, keep 3 old files (~8 MB worst case for a dev tool's log). */
|
||||||
|
const MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
const KEEP = 3
|
||||||
|
|
||||||
|
interface LoggerState {
|
||||||
|
file: string | null
|
||||||
|
dir: string | null
|
||||||
|
min: number
|
||||||
|
mirror: boolean
|
||||||
|
/** Byte size tracked in-process so the common path avoids a stat() per line. */
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: LoggerState = { file: null, dir: null, min: LEVELS.debug, mirror: false, size: 0 }
|
||||||
|
|
||||||
|
/** Absolute path of the active log file, or null before initLogger(). */
|
||||||
|
export function getLogPath(): string | null {
|
||||||
|
return state.file
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogDir(): string | null {
|
||||||
|
return state.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point the logger at `dir` (created if needed). Safe to call once per process.
|
||||||
|
* `mirror` also echoes to the console, which is useful in `npm run dev` where a
|
||||||
|
* terminal IS attached. `level` gates the floor (default: everything).
|
||||||
|
*/
|
||||||
|
export function initLogger(opts: { dir: string; mirror?: boolean; level?: LogLevel }): void {
|
||||||
|
state.dir = opts.dir
|
||||||
|
state.file = join(opts.dir, 'helder.log')
|
||||||
|
state.mirror = !!opts.mirror
|
||||||
|
state.min = LEVELS[opts.level ?? 'debug']
|
||||||
|
try {
|
||||||
|
mkdirSync(opts.dir, { recursive: true })
|
||||||
|
state.size = statSync(state.file).size
|
||||||
|
} catch {
|
||||||
|
// Missing file is the normal first-run case (size stays 0). A genuinely
|
||||||
|
// unwritable dir surfaces on the first write() instead, which no-ops.
|
||||||
|
state.size = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `helder.log` → `helder.1.log` → … → dropped after KEEP. Called when the live
|
||||||
|
* file crosses MAX_BYTES. Several project processes share one file and could in
|
||||||
|
* principle rotate at the same moment; the renames are best-effort and a lost
|
||||||
|
* race costs at most some log lines, never a crash — hence the blanket catch.
|
||||||
|
*/
|
||||||
|
function rotate(): void {
|
||||||
|
const dir = state.dir
|
||||||
|
const file = state.file
|
||||||
|
if (!dir || !file) return
|
||||||
|
try {
|
||||||
|
const oldest = join(dir, `helder.${KEEP}.log`)
|
||||||
|
try { unlinkSync(oldest) } catch { /* wasn't there */ }
|
||||||
|
for (let i = KEEP - 1; i >= 1; i--) {
|
||||||
|
try { renameSync(join(dir, `helder.${i}.log`), join(dir, `helder.${i + 1}.log`)) } catch { /* gap in the chain */ }
|
||||||
|
}
|
||||||
|
renameSync(file, join(dir, 'helder.1.log'))
|
||||||
|
state.size = 0
|
||||||
|
} catch { /* another process rotated first; keep appending */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON that can't throw on cycles/BigInt — a logger must never be the crash. */
|
||||||
|
function safeJson(value: unknown): string {
|
||||||
|
const seen = new WeakSet<object>()
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, (_k, v) => {
|
||||||
|
if (typeof v === 'bigint') return `${v}n`
|
||||||
|
if (typeof v === 'function') return `[Function ${v.name || 'anonymous'}]`
|
||||||
|
if (typeof v === 'object' && v !== null) {
|
||||||
|
if (seen.has(v as object)) return '[Circular]'
|
||||||
|
seen.add(v as object)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}) ?? String(value)
|
||||||
|
} catch {
|
||||||
|
return '[unserializable]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise anything thrown into a loggable shape. Non-Errors get stringified
|
||||||
|
* (people throw strings), and `cause` is followed so wrapped errors keep their
|
||||||
|
* root cause — usually the line that actually explains the failure.
|
||||||
|
*/
|
||||||
|
export function formatErr(e: unknown): { message: string; stack?: string; cause?: string } {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
const out: { message: string; stack?: string; cause?: string } = { message: e.message }
|
||||||
|
if (e.stack) out.stack = e.stack
|
||||||
|
if (e.cause !== undefined) out.cause = e.cause instanceof Error ? (e.cause.stack || e.cause.message) : safeJson(e.cause)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return { message: typeof e === 'string' ? e : safeJson(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One log line: ISO ts · level · pid · scope · message · context JSON.
|
||||||
|
*
|
||||||
|
* Continuation lines are indented, never bare: a message can carry newlines of
|
||||||
|
* its own (Electron's console warnings do, and so does any stack passed as the
|
||||||
|
* message), and an unindented second line is indistinguishable from a new entry
|
||||||
|
* to both a human skimming the file and to `grep`.
|
||||||
|
*/
|
||||||
|
export function formatLine(level: LogLevel, scope: string, msg: string, ctx: unknown, pid: number, now: string): string {
|
||||||
|
const head = `${now} ${level.toUpperCase().padEnd(5)} ${String(pid).padStart(5)} ${scope.padEnd(9)} ${indent(msg)}`
|
||||||
|
if (ctx === undefined) return head + '\n'
|
||||||
|
return head + ' ' + indent(safeJson(ctx)) + '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
function indent(s: string): string {
|
||||||
|
return s.replace(/\r?\n/g, '\n ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(level: LogLevel, scope: string, msg: string, ctx?: unknown): void {
|
||||||
|
if (LEVELS[level] < state.min) return
|
||||||
|
const line = formatLine(level, scope, msg, ctx, process.pid, new Date().toISOString())
|
||||||
|
|
||||||
|
if (state.mirror) {
|
||||||
|
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||||
|
fn(line.trimEnd())
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = state.file
|
||||||
|
if (!file) return
|
||||||
|
if (state.size + line.length > MAX_BYTES) rotate()
|
||||||
|
try {
|
||||||
|
// Sync + O_APPEND: the write lands before an imminent crash can eat it, and
|
||||||
|
// concurrent appends from sibling project processes don't interleave.
|
||||||
|
appendFileSync(file, line, { encoding: 'utf8' })
|
||||||
|
state.size += Buffer.byteLength(line)
|
||||||
|
} catch { /* disk full / no permission — never let logging break the app */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
debug: (scope: string, msg: string, ctx?: unknown): void => log('debug', scope, msg, ctx),
|
||||||
|
info: (scope: string, msg: string, ctx?: unknown): void => log('info', scope, msg, ctx),
|
||||||
|
warn: (scope: string, msg: string, ctx?: unknown): void => log('warn', scope, msg, ctx),
|
||||||
|
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||||
|
log('error', scope, msg, err === undefined ? ctx : { ...ctx, err: formatErr(err) }),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset for tests. Not used by the app. */
|
||||||
|
export function _resetLogger(): void {
|
||||||
|
state.file = null; state.dir = null; state.min = LEVELS.debug; state.mirror = false; state.size = 0
|
||||||
|
}
|
||||||
28
src/main/notes-service.ts
Normal file
28
src/main/notes-service.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Project scratch note: a plain text file at `<project>/.notes.txt`.
|
||||||
|
*
|
||||||
|
* Deliberately not JSON and not part of `.helder/`. It is a note the user
|
||||||
|
* writes by hand, so it must stay readable and editable outside Helder.
|
||||||
|
*/
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
export const NOTES_FILE = '.notes.txt'
|
||||||
|
|
||||||
|
/** The note's text. Empty string when the project has no note yet. */
|
||||||
|
export async function readNote(root: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(join(root, NOTES_FILE), 'utf8')
|
||||||
|
} catch (err) {
|
||||||
|
// ENOENT is the normal "no note yet" case, anything else is worth knowing.
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
logger.warn('notes', 'read failed', { root, err: String(err) })
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeNote(root: string, text: string): Promise<void> {
|
||||||
|
await writeFile(join(root, NOTES_FILE), text, 'utf8')
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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)' }}>Can’t 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 & 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 & 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>
|
||||||
|
|||||||
14
src/renderer/src/env.d.ts
vendored
14
src/renderer/src/env.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
75
src/renderer/src/log.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Renderer → main log bridge. Everything here ends up in the SAME file the main
|
||||||
|
* process writes, so a crash reads as one chronological story ("git:load failed
|
||||||
|
* … then the render process went OOM") instead of two disconnected halves.
|
||||||
|
*
|
||||||
|
* Without this, a renderer exception only ever reached DevTools — which nobody
|
||||||
|
* has open at the moment things actually break.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Level = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
interface ErrLike { message: string; stack?: string; name?: string }
|
||||||
|
|
||||||
|
/** Structured-clone-safe: an Error survives IPC as `{}` unless unpacked here. */
|
||||||
|
function pack(e: unknown): ErrLike | { value: string } {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
const out: ErrLike = { message: e.message, name: e.name }
|
||||||
|
if (e.stack) out.stack = e.stack
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if (typeof e === 'string') return { value: e }
|
||||||
|
try { return { value: JSON.stringify(e) ?? String(e) } } catch { return { value: String(e) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(level: Level, scope: string, msg: string, ctx?: unknown): void {
|
||||||
|
const bridge = window.helder
|
||||||
|
// No bridge = browser preview (or a broken preload). Console is all we have.
|
||||||
|
if (!bridge?.log) {
|
||||||
|
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log
|
||||||
|
fn(`[helder] ${scope}: ${msg}`, ctx ?? '')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try { bridge.log.write(level, scope, msg, ctx) } catch { /* never let logging throw */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rlog = {
|
||||||
|
debug: (scope: string, msg: string, ctx?: unknown): void => send('debug', scope, msg, ctx),
|
||||||
|
info: (scope: string, msg: string, ctx?: unknown): void => send('info', scope, msg, ctx),
|
||||||
|
warn: (scope: string, msg: string, ctx?: unknown): void => send('warn', scope, msg, ctx),
|
||||||
|
error: (scope: string, msg: string, err?: unknown, ctx?: Record<string, unknown>): void =>
|
||||||
|
send('error', scope, msg, err === undefined ? ctx : { ...ctx, err: pack(err) }),
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = false
|
||||||
|
|
||||||
|
/** Hook the renderer's global failure paths. Call once, as early as possible. */
|
||||||
|
export function installErrorLogging(): void {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
|
||||||
|
// Uncaught throws outside React's render phase: event handlers, timers, and
|
||||||
|
// the async IPC callbacks that make up most of this app.
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
// Resource load failures (a missing font/image) arrive here with no `error`
|
||||||
|
// and target the element — worth a line, but they aren't exceptions.
|
||||||
|
if (e.error === undefined && e.target && e.target !== window) {
|
||||||
|
const el = e.target as HTMLElement & { src?: string; href?: string }
|
||||||
|
rlog.warn('resource', `failed to load ${el.tagName?.toLowerCase?.() ?? 'resource'}`, { url: el.src || el.href || '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rlog.error('renderer', e.message || 'uncaught error', e.error, { source: e.filename, line: e.lineno, col: e.colno })
|
||||||
|
}, true)
|
||||||
|
|
||||||
|
// The one that matters most here: every window.helder.* call is a promise, so
|
||||||
|
// a rejected IPC with no .catch() lands here and nowhere else.
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
rlog.error('renderer', 'unhandled promise rejection', e.reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
rlog.info('renderer', 'window loaded', {
|
||||||
|
url: location.href,
|
||||||
|
bridge: !!window.helder,
|
||||||
|
screen: `${window.innerWidth}x${window.innerHeight}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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 }: {
|
||||||
|
|||||||
@@ -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] ?? ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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); }
|
||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
83
test/editor-tab.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
|
||||||
|
})
|
||||||
|
afterEach(() => { cleanup(); localStorage.clear() })
|
||||||
|
|
||||||
|
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
|
||||||
|
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.trim() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a changed file and switch to the writable buffer.
|
||||||
|
async function openEditor(): Promise<HTMLTextAreaElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
const row = await waitFor(() => {
|
||||||
|
const r = Array.from(c.querySelectorAll<HTMLElement>('.git-row')).find((el) => el.textContent?.includes('UserController.php'))
|
||||||
|
if (!r) throw new Error('git not ready')
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
fireEvent.click(row)
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
fireEvent.click(find(c, '.seg button', 'Actual')!)
|
||||||
|
return await waitFor(() => {
|
||||||
|
const ta = c.querySelector<HTMLTextAreaElement>('.ce-ta')
|
||||||
|
if (!ta) throw new Error('no buffer')
|
||||||
|
return ta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(ta: HTMLTextAreaElement, shift = false): boolean {
|
||||||
|
return fireEvent.keyDown(ta, { key: 'Tab', shiftKey: shift })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tab in the code editor', () => {
|
||||||
|
it('inserts four spaces at the caret instead of moving focus', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
const before = ta.value
|
||||||
|
ta.setSelectionRange(0, 0)
|
||||||
|
|
||||||
|
// fireEvent returns false when a handler called preventDefault, which is
|
||||||
|
// what stops the browser tabbing focus over to the agent column.
|
||||||
|
expect(tab(ta)).toBe(false)
|
||||||
|
await waitFor(() => expect(ta.value).toBe(' ' + before))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indents every line a multi-line selection touches', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
const lines = ta.value.split('\n')
|
||||||
|
// Select from inside line 1 to inside line 2.
|
||||||
|
ta.setSelectionRange(1, lines[0].length + 2)
|
||||||
|
|
||||||
|
tab(ta)
|
||||||
|
await waitFor(() => {
|
||||||
|
const now = ta.value.split('\n')
|
||||||
|
expect(now[0]).toBe(' ' + lines[0])
|
||||||
|
expect(now[1]).toBe(' ' + lines[1])
|
||||||
|
expect(now[2]).toBe(lines[2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shift+Tab outdents the current line', async () => {
|
||||||
|
const ta = await openEditor()
|
||||||
|
ta.setSelectionRange(0, 0)
|
||||||
|
tab(ta)
|
||||||
|
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(true))
|
||||||
|
|
||||||
|
ta.setSelectionRange(6, 6)
|
||||||
|
expect(tab(ta, true)).toBe(false)
|
||||||
|
await waitFor(() => expect(ta.value.startsWith(' ')).toBe(false))
|
||||||
|
})
|
||||||
|
})
|
||||||
168
test/git-two-rows.test.tsx
Normal file
168
test/git-two-rows.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// A file can be staged and then edited again. Git calls that "MM": two rows,
|
||||||
|
// one per group. These tests drive the renderer with such a payload and check
|
||||||
|
// that Diff follows the row you clicked, while Original and Actual do not.
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
const HEAD = 'a\nb\nc\n'
|
||||||
|
const INDEX = 'a\nSTAGED\nc\n'
|
||||||
|
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
|
||||||
|
|
||||||
|
/** Minimal preload bridge: enough for the store to boot with real git rows. */
|
||||||
|
function stubBridge(): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
|
||||||
|
readDir: async () => [],
|
||||||
|
files: async () => ({ 'demo.txt': DISK }),
|
||||||
|
read: async () => DISK,
|
||||||
|
imageDataUrl: async () => '',
|
||||||
|
write: async () => {},
|
||||||
|
delete: async () => {},
|
||||||
|
create: async () => {},
|
||||||
|
mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: { read: async () => '', write: async () => {} },
|
||||||
|
git: {
|
||||||
|
// Exactly what git-service now returns for porcelain "MM".
|
||||||
|
load: async () => ({
|
||||||
|
branch: 'main',
|
||||||
|
changes: [
|
||||||
|
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
|
||||||
|
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onProjectChanged: off,
|
||||||
|
onConfigChanged: off,
|
||||||
|
onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(stubBridge)
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Row text of the view currently on screen. */
|
||||||
|
function viewText(c: HTMLElement): string {
|
||||||
|
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
|
||||||
|
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
|
||||||
|
const head = heads.find((h) => h.textContent?.startsWith(label))!
|
||||||
|
const rows: HTMLElement[] = []
|
||||||
|
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
|
||||||
|
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
|
||||||
|
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
await waitFor(() => {
|
||||||
|
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
|
||||||
|
})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a file that is staged and then edited again', () => {
|
||||||
|
it('shows up in both groups', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||||
|
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Diff on the staged row compares HEAD with the staged copy', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
fireEvent.click(group(c, 'Staged Changes')[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
const text = viewText(c)
|
||||||
|
expect(text).toContain('b')
|
||||||
|
expect(text).toContain('STAGED')
|
||||||
|
// The later edit is not part of what is staged, so it must not show here.
|
||||||
|
expect(text).not.toContain('AFTER-STAGING')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Diff on the unstaged row compares the staged copy with disk', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
fireEvent.click(group(c, 'Changes')[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
const text = viewText(c)
|
||||||
|
expect(text).toContain('AFTER-STAGING')
|
||||||
|
// 'b' was already replaced before staging, so this half must not mention it.
|
||||||
|
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels which pair the diff is comparing', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
fireEvent.click(group(c, 'Staged Changes')[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
|
||||||
|
fireEvent.click(group(c, 'Changes')[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
for (const label of ['Staged Changes', 'Changes'] as const) {
|
||||||
|
fireEvent.click(group(c, label)[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
|
||||||
|
|
||||||
|
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
|
||||||
|
fireEvent.click(original)
|
||||||
|
await waitFor(() => expect(viewText(c)).toContain('b'))
|
||||||
|
expect(viewText(c)).not.toContain('AFTER-STAGING')
|
||||||
|
|
||||||
|
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
|
||||||
|
fireEvent.click(actual)
|
||||||
|
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
|
||||||
|
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only the row you opened is highlighted', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
fireEvent.click(group(c, 'Changes')[0])
|
||||||
|
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
|
||||||
|
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
|
||||||
|
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
151
test/logger.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { _resetLogger, formatErr, formatLine, getLogPath, initLogger, log, logger } from '../src/main/logger'
|
||||||
|
|
||||||
|
let dir: string
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), 'helder-log-'))
|
||||||
|
_resetLogger()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
_resetLogger()
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function read(): string {
|
||||||
|
return readFileSync(join(dir, 'helder.log'), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatLine', () => {
|
||||||
|
it('lays out ts, padded level, pid, scope and message', () => {
|
||||||
|
const line = formatLine('info', 'git', 'loaded', undefined, 42, '2026-07-15T10:00:00.000Z')
|
||||||
|
expect(line).toBe('2026-07-15T10:00:00.000Z INFO 42 git loaded\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends context as JSON', () => {
|
||||||
|
const line = formatLine('warn', 'ipc', 'slow', { ms: 1200 }, 7, '2026-07-15T10:00:00.000Z')
|
||||||
|
expect(line).toContain('{"ms":1200}')
|
||||||
|
expect(line.endsWith('\n')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indents a multi-line message so a continuation never looks like a new entry', () => {
|
||||||
|
// Electron's own console warnings arrive with embedded newlines.
|
||||||
|
const line = formatLine('warn', 'console', 'line one\nline two', undefined, 1, 'T')
|
||||||
|
expect(line).toBe('T WARN 1 console line one\n line two\n')
|
||||||
|
// Every line after the first is indented → an entry always starts at col 0.
|
||||||
|
for (const l of line.trimEnd().split('\n').slice(1)) expect(l.startsWith(' ')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('indents multi-line context too', () => {
|
||||||
|
const line = formatLine('error', 'x', 'boom', { stack: 'a\nb' }, 1, 'T')
|
||||||
|
// JSON escapes the \n inside the string value, so this stays a single line.
|
||||||
|
expect(line.split('\n').filter(Boolean)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatErr', () => {
|
||||||
|
it('keeps message and stack', () => {
|
||||||
|
const e = new Error('nope')
|
||||||
|
const out = formatErr(e)
|
||||||
|
expect(out.message).toBe('nope')
|
||||||
|
expect(out.stack).toContain('nope')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows cause — the line that usually explains the failure', () => {
|
||||||
|
const root = new Error('EACCES')
|
||||||
|
const e = new Error('save failed', { cause: root })
|
||||||
|
expect(formatErr(e).cause).toContain('EACCES')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a thrown string', () => {
|
||||||
|
expect(formatErr('just a string').message).toBe('just a string')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives a thrown non-Error object', () => {
|
||||||
|
expect(formatErr({ code: 7 }).message).toBe('{"code":7}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw on circular values', () => {
|
||||||
|
const a: Record<string, unknown> = {}
|
||||||
|
a.self = a
|
||||||
|
expect(() => formatErr(a)).not.toThrow()
|
||||||
|
expect(formatErr(a).message).toContain('Circular')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('log', () => {
|
||||||
|
it('creates the file and appends lines', () => {
|
||||||
|
initLogger({ dir })
|
||||||
|
logger.info('boot', 'hello')
|
||||||
|
logger.warn('boot', 'careful')
|
||||||
|
const out = read()
|
||||||
|
expect(out).toContain('INFO')
|
||||||
|
expect(out).toContain('hello')
|
||||||
|
expect(out).toContain('WARN')
|
||||||
|
expect(out.trim().split('\n')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a missing directory', () => {
|
||||||
|
const nested = join(dir, 'a', 'b')
|
||||||
|
initLogger({ dir: nested })
|
||||||
|
logger.info('x', 'y')
|
||||||
|
expect(existsSync(join(nested, 'helder.log'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records the error with its stack', () => {
|
||||||
|
initLogger({ dir })
|
||||||
|
logger.error('save', 'write failed', new Error('EACCES: permission denied'), { path: 'a.ts' })
|
||||||
|
const out = read()
|
||||||
|
expect(out).toContain('EACCES: permission denied')
|
||||||
|
expect(out).toContain('"path":"a.ts"')
|
||||||
|
expect(out).toContain('"stack"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours the level floor', () => {
|
||||||
|
initLogger({ dir, level: 'warn' })
|
||||||
|
logger.debug('x', 'debug line')
|
||||||
|
logger.info('x', 'info line')
|
||||||
|
logger.error('x', 'error line')
|
||||||
|
const out = read()
|
||||||
|
expect(out).not.toContain('debug line')
|
||||||
|
expect(out).not.toContain('info line')
|
||||||
|
expect(out).toContain('error line')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is a no-op before initLogger rather than throwing', () => {
|
||||||
|
expect(() => logger.info('x', 'y')).not.toThrow()
|
||||||
|
expect(getLogPath()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes the active path', () => {
|
||||||
|
initLogger({ dir })
|
||||||
|
expect(getLogPath()).toBe(join(dir, 'helder.log'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never throws even when the log path is unwritable', () => {
|
||||||
|
// Point at a path whose parent is a FILE: every append must fail.
|
||||||
|
const wall = join(dir, 'wall')
|
||||||
|
writeFileSync(wall, 'x')
|
||||||
|
initLogger({ dir: join(wall, 'sub') })
|
||||||
|
expect(() => logger.error('x', 'still fine')).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('rotation', () => {
|
||||||
|
it('rolls the file once it passes the size cap and keeps writing', () => {
|
||||||
|
initLogger({ dir })
|
||||||
|
const big = 'x'.repeat(4000)
|
||||||
|
// 2MB cap / ~4KB per line → ~525 lines to trip it. 700 is comfortably past.
|
||||||
|
for (let i = 0; i < 700; i++) log('info', 'bulk', big)
|
||||||
|
|
||||||
|
expect(existsSync(join(dir, 'helder.1.log'))).toBe(true)
|
||||||
|
// The live file is the post-rotation one and is still being appended to.
|
||||||
|
logger.info('after', 'still logging')
|
||||||
|
expect(read()).toContain('still logging')
|
||||||
|
// And it's small again — proof the rotation actually moved the bytes.
|
||||||
|
expect(read().length).toBeLessThan(4000 * 700)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
200
test/notes.test.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The project note: ⌘N opens it, the text lands in <project>/.notes.txt, and it
|
||||||
|
// is written whenever the window loses focus.
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
let stored = ''
|
||||||
|
let writes: string[] = []
|
||||||
|
|
||||||
|
function stubBridge(): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||||
|
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||||
|
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||||
|
create: async () => {}, mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: {
|
||||||
|
read: async () => stored,
|
||||||
|
write: async (t: string) => { stored = t; writes.push(t) },
|
||||||
|
},
|
||||||
|
git: {
|
||||||
|
load: async () => ({ branch: 'main', changes: [] }),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(() => { stored = ''; writes = []; stubBridge() })
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
})
|
||||||
|
|
||||||
|
async function boot(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressCmdN(): void {
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', metaKey: true })) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the note. The shortcut is registered in an effect, which React may not
|
||||||
|
* have flushed yet when the first paint lands, so keep pressing until it takes.
|
||||||
|
* A second ⌘N with the overlay already open is a no-op. */
|
||||||
|
async function openNote(c: HTMLElement): Promise<HTMLTextAreaElement> {
|
||||||
|
await waitFor(() => {
|
||||||
|
pressCmdN()
|
||||||
|
if (!c.querySelector('.notes-modal')) throw new Error('note not open')
|
||||||
|
})
|
||||||
|
return c.querySelector('.notes-input') as HTMLTextAreaElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurWindow(): void {
|
||||||
|
act(() => { window.dispatchEvent(new Event('blur')) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('project note', () => {
|
||||||
|
it('⌘N opens the note overlay', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
expect(c.querySelector('.notes-modal')).toBeNull()
|
||||||
|
await openNote(c)
|
||||||
|
expect(c.querySelector('.notes-modal .notes-file')?.textContent).toBe('.notes.txt')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads the note that is already on disk', async () => {
|
||||||
|
stored = 'earlier thoughts\n'
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
await waitFor(() => expect(ta.value).toBe('earlier thoughts\n'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes the note when the window loses focus', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'buy milk' } })
|
||||||
|
|
||||||
|
expect(writes).toEqual([]) // nothing written while typing
|
||||||
|
blurWindow()
|
||||||
|
await waitFor(() => expect(writes).toEqual(['buy milk']))
|
||||||
|
expect(stored).toBe('buy milk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not rewrite the file when nothing changed', async () => {
|
||||||
|
stored = 'unchanged'
|
||||||
|
await boot()
|
||||||
|
await waitFor(() => expect(stored).toBe('unchanged'))
|
||||||
|
blurWindow()
|
||||||
|
blurWindow()
|
||||||
|
expect(writes).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Esc closes the note and saves it right away', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'quick capture' } })
|
||||||
|
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
await waitFor(() => expect(writes).toEqual(['quick capture']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⌘P passes the note to the agent, then saves and closes it', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'refactor the policy' } })
|
||||||
|
|
||||||
|
const seen: string[] = []
|
||||||
|
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||||
|
window.addEventListener('agentPaste', onPaste)
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||||
|
window.removeEventListener('agentPaste', onPaste)
|
||||||
|
|
||||||
|
expect(seen).toEqual(['refactor the policy'])
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
await waitFor(() => expect(writes).toEqual(['refactor the policy']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('⌘P does nothing when the note is empty', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
await openNote(c)
|
||||||
|
|
||||||
|
const seen: string[] = []
|
||||||
|
const onPaste = (e: Event): void => { seen.push((e as CustomEvent<string>).detail) }
|
||||||
|
window.addEventListener('agentPaste', onPaste)
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'p', metaKey: true })) })
|
||||||
|
window.removeEventListener('agentPaste', onPaste)
|
||||||
|
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
expect(c.querySelector('.notes-modal')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the header shows the pass-to-agent hint', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
await openNote(c)
|
||||||
|
const hint = c.querySelector('.notes-modal .notes-hint')
|
||||||
|
expect(hint?.textContent).toContain('To agent')
|
||||||
|
expect(hint?.querySelector('kbd')?.textContent).toBe('⌘P')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the title bar carries the ⌘N note action', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const btn = [...c.querySelectorAll('.titlebar .tb-btn')]
|
||||||
|
.find((b) => b.textContent?.includes('Note')) as HTMLButtonElement | undefined
|
||||||
|
expect(btn?.querySelector('kbd')?.textContent).toBe('⌘N')
|
||||||
|
expect(btn?.className).not.toContain('on')
|
||||||
|
|
||||||
|
act(() => { btn?.click() })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).not.toBeNull())
|
||||||
|
// The action reads as "on" while the note is open, like the other toggles.
|
||||||
|
expect(btn?.className).toContain('on')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the text when reopened', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const ta = await openNote(c)
|
||||||
|
fireEvent.change(ta, { target: { value: 'still here' } })
|
||||||
|
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||||
|
await waitFor(() => expect(c.querySelector('.notes-modal')).toBeNull())
|
||||||
|
|
||||||
|
const again = await openNote(c)
|
||||||
|
expect(again.value).toBe('still here')
|
||||||
|
})
|
||||||
|
})
|
||||||
105
test/titlebar.test.tsx
Normal file
105
test/titlebar.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The title bar: borderless actions that go accent when on, and the fullscreen
|
||||||
|
// shift (macOS hides the traffic lights, so the project name moves to the edge).
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
vi.mock('../src/renderer/src/terminals', () => {
|
||||||
|
let n = 0
|
||||||
|
return { Terminal: () => null, lid: () => ++n }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { App } from '../src/renderer/src/App'
|
||||||
|
import { ProjectProvider } from '../src/renderer/src/project'
|
||||||
|
|
||||||
|
/** Fires the fullscreen callbacks the App subscribed to. */
|
||||||
|
let fullscreenCbs: ((on: boolean) => void)[] = []
|
||||||
|
|
||||||
|
function stubBridge(): void {
|
||||||
|
const noop = (): void => {}
|
||||||
|
const off = (): (() => void) => noop
|
||||||
|
;(window as unknown as { helder: unknown }).helder = {
|
||||||
|
platform: 'darwin',
|
||||||
|
clipboard: { writeText: noop, readText: () => '' },
|
||||||
|
project: {
|
||||||
|
current: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
open: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
openPath: async () => ({ root: '/repo', name: 'repo' }),
|
||||||
|
recent: async () => [],
|
||||||
|
},
|
||||||
|
fs: {
|
||||||
|
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [] }),
|
||||||
|
readDir: async () => [], files: async () => ({}), read: async () => '',
|
||||||
|
imageDataUrl: async () => '', write: async () => {}, delete: async () => {},
|
||||||
|
create: async () => {}, mkdir: async () => {},
|
||||||
|
},
|
||||||
|
shell: { reveal: noop },
|
||||||
|
notes: { read: async () => '', write: async () => {} },
|
||||||
|
git: {
|
||||||
|
load: async () => ({ branch: 'main', changes: [] }),
|
||||||
|
stage: async () => {}, unstage: async () => {}, commit: async () => {},
|
||||||
|
push: async () => ({ ok: true, message: '' }), discard: async () => {},
|
||||||
|
},
|
||||||
|
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
|
||||||
|
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
|
||||||
|
recent: { get: async () => [], set: async () => {} },
|
||||||
|
search: { content: async () => [], files: async () => [] },
|
||||||
|
dialog: { unsavedClose: async () => 'cancel' },
|
||||||
|
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
|
||||||
|
onFullscreen: (cb: (on: boolean) => void) => {
|
||||||
|
fullscreenCbs.push(cb)
|
||||||
|
return () => { fullscreenCbs = fullscreenCbs.filter((f) => f !== cb) }
|
||||||
|
},
|
||||||
|
onProjectChanged: off, onConfigChanged: off, onRefresh: off,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
|
||||||
|
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
|
||||||
|
})
|
||||||
|
beforeEach(() => { fullscreenCbs = []; stubBridge() })
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
delete (window as unknown as { helder?: unknown }).helder
|
||||||
|
})
|
||||||
|
|
||||||
|
async function boot(): Promise<HTMLElement> {
|
||||||
|
const c = render(<ProjectProvider><App /></ProjectProvider>).container
|
||||||
|
await waitFor(() => { if (!c.querySelector('.git-foot')) throw new Error('not ready') })
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFullscreen(on: boolean): void {
|
||||||
|
act(() => { fullscreenCbs.forEach((cb) => cb(on)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('title bar', () => {
|
||||||
|
it('shifts left in fullscreen and back out again', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const bar = c.querySelector('.titlebar') as HTMLElement
|
||||||
|
expect(bar.className).not.toContain('fullscreen')
|
||||||
|
|
||||||
|
await waitFor(() => expect(fullscreenCbs.length).toBe(1))
|
||||||
|
setFullscreen(true)
|
||||||
|
expect(bar.className).toContain('fullscreen')
|
||||||
|
|
||||||
|
setFullscreen(false)
|
||||||
|
expect(bar.className).not.toContain('fullscreen')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the state with the accent, not an On/Off badge', async () => {
|
||||||
|
const c = await boot()
|
||||||
|
const hidden = [...c.querySelectorAll<HTMLButtonElement>('.titlebar .tb-btn')]
|
||||||
|
.find((b) => b.textContent?.includes('Hidden')) as HTMLButtonElement
|
||||||
|
expect(c.querySelector('.titlebar .tb-state')).toBeNull()
|
||||||
|
expect(hidden.className).not.toContain('on')
|
||||||
|
|
||||||
|
act(() => { hidden.click() })
|
||||||
|
await waitFor(() => expect(hidden.className).toContain('on'))
|
||||||
|
expect(hidden.textContent).not.toContain('On')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user