This commit is contained in:
4
.helder/.gitignore
vendored
4
.helder/.gitignore
vendored
@@ -1,2 +1,2 @@
|
|||||||
# Helder — local, machine-specific state (do not commit)
|
# Helder — local, machine-specific state (do not commit).
|
||||||
recent.json
|
*
|
||||||
|
|||||||
75
OVERNIGHT.md
75
OVERNIGHT.md
@@ -1,75 +0,0 @@
|
|||||||
# Overnight work log
|
|
||||||
|
|
||||||
Autonomous session continuing the Helder build while you slept. Everything kept
|
|
||||||
compiling (`npm run build`), type-checking (`npm run typecheck`) and — new this
|
|
||||||
session — passing tests (`npm run test`). Git left uncommitted for you to review.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
1. Packaging (electron-builder)
|
|
||||||
2. Automated test suite (vitest)
|
|
||||||
3. Lint + format (eslint + prettier)
|
|
||||||
4. Robustness + feature polish
|
|
||||||
5. Developer README
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
### 1. Packaging — done
|
|
||||||
- Added `electron-builder.yml` (appId `com.blijnder.helder`, mac dmg+zip / win nsis / linux AppImage, unsigned local build).
|
|
||||||
- Scripts: `npm run pack` (`--dir`), `npm run dist`, `npm run dist:mac`.
|
|
||||||
- Made the bundled ripgrep path asar-safe (redirect to `app.asar.unpacked`).
|
|
||||||
- `asarUnpack` for `node-pty` + `@vscode/ripgrep` so the native `.node` and `rg` binary load at runtime.
|
|
||||||
- Verified: `npm run pack` produced `dist/mac-arm64/Helder.app` (245 MB) with `pty.node` + `rg` correctly unpacked. Unsigned (ad-hoc), default icon. `dist/` gitignored.
|
|
||||||
|
|
||||||
### 2. Automated tests — done (vitest)
|
|
||||||
- Added vitest + `vitest.config.ts`; scripts `npm run test` / `test:watch`. **38 tests, all green.**
|
|
||||||
- `test/diff.test.ts` — LCS diff: no-change, single change, new/deleted file, side marks, split alignment, trailing-newline.
|
|
||||||
- `test/fuzzy.test.ts` — subsequence matcher (extracted to `src/renderer/src/fuzzy.ts`).
|
|
||||||
- `test/highlight.test.ts` — ext/lang/label/icon/escape + Prism php highlighting (confirms markup-templating load order).
|
|
||||||
- `test/config.test.ts` — `.helder` defaults regen, sparse deep-merge, theme.css not overwritten.
|
|
||||||
- `test/fs.test.ts` — tree dirs-first + ignore dirs, content index skips binaries/node_modules, read/write round-trip.
|
|
||||||
- `test/git.test.ts` — `classify` unit + real temp-repo integration (modified/untracked/staged, HEAD-vs-worktree text).
|
|
||||||
|
|
||||||
### 3. Lint + format — done
|
|
||||||
- ESLint flat config (`eslint.config.js`): typescript-eslint recommended + react-hooks, prettier-disables, sensible ignores. `npm run lint` = **0 errors** (8 intentional exhaustive-deps warnings).
|
|
||||||
- Fixed real `no-unused-expressions` violations (short-circuit/ternary-as-statement).
|
|
||||||
- `.prettierrc.json` added (style: no-semi, single-quote, width 140). Not auto-applied to avoid churn.
|
|
||||||
|
|
||||||
### 4. Robustness + feature polish — done
|
|
||||||
- **Fixed a real `discard` bug**: `git checkout` can't remove a new/untracked file. `discard` now reverts modified→HEAD, restores deleted, and removes new/untracked (staged or not). Covered by new git tests.
|
|
||||||
- **Close-tab guard**: confirms before closing a tab with unsaved edits (× / middle-click / ⌘W), and clears its buffer on close. Reads fresh state via refs so the ⌘W path is correct too.
|
|
||||||
- **ErrorBoundary** around the whole app — a render fault shows a dark, recoverable panel (with the message + Reload) instead of a blank window.
|
|
||||||
- **`editor.tabSize` wired** into the editable buffer (textarea + highlighted pre) and the status bar — config now visibly does something.
|
|
||||||
- **Layout persistence**: the three column widths + the terminal split fraction persist across launches via localStorage (`persist.ts`), making the README's "positions persist across launches" claim true.
|
|
||||||
|
|
||||||
### 5. Developer docs + icon — done
|
|
||||||
- `README.md` updated from "greenfield" to the real working build: getting-started, scripts table, repository layout.
|
|
||||||
- App icon generated from Helder's spark mark → `build/icon.png` (1024²); electron-builder embeds `icon.icns` in the packaged app (verified).
|
|
||||||
|
|
||||||
### 6. Renderer component tests + CI — done
|
|
||||||
- Added jsdom + `@testing-library/react`; the terminals (xterm) are mocked so tests stay deterministic.
|
|
||||||
- `test/app.test.tsx` — workbench renders, open changed file → diff tab, edit → dirty tab, content search returns hits.
|
|
||||||
- `test/app-interactions.test.tsx` — **Pass-on-to-Agent** emits exactly `"<note> <path:line>"` via the `agentPaste` event; **stage → commit** toasts.
|
|
||||||
- `test/editor-modes.test.tsx` — Updated mode is editable / Original is read-only; Split opens the two-pane overlay and Esc collapses it.
|
|
||||||
- **49 tests total, all green.**
|
|
||||||
- `.github/workflows/ci.yml` — runs typecheck · lint · test · build on push/PR.
|
|
||||||
|
|
||||||
### 7. Finish to the DESIGN.md spec — done
|
|
||||||
Audited `DESIGN.md` section by section and closed every remaining behavioral gap:
|
|
||||||
- **Session restore** (`session.restoreOnLaunch`, §9) — open tabs, active tab, and per-tab view modes are restored per project (persisted in localStorage, keyed by root); splitter layout already persisted.
|
|
||||||
- **Close-dirty tab** (§5) — now a real **Save / Don't Save / Cancel** native dialog (was discard-or-cancel). `⌘S` save unchanged.
|
|
||||||
- **Unsaved indicator** (§5) — a **dot in place of the close control** (× returns on hover), matching the spec exactly (was a dot after the name).
|
|
||||||
- **New config keys, all wired**: `git.confirmStage` / `git.confirmUnstage` (default off, prompt when on), `git.defaultDiffMode` (drives the starting mode), `files.exclude` + `files.followGitignore`, `session.restoreOnLaunch`.
|
|
||||||
- **Unified file source**: `rg --files` is now the single source for the **Explorer tree + content index + search**, so **gitignore and `files.exclude` are honored consistently everywhere** (fs-walk fallback when ripgrep is absent). Dotfiles (`.env`, `.gitignore`) included via `--hidden`.
|
|
||||||
- **⌘P** aliases the unified search — no separate Go-to-File overlay, per the resolved decision (CLAUDE.md/README).
|
|
||||||
- Tests now **51** (added `buildTreeFromPaths`, gitignore-in-repo, updated dirty-tab class).
|
|
||||||
|
|
||||||
### 8. Live-run bugfix (found by running `npm run dev`)
|
|
||||||
- **Symptom:** the real Electron window showed mock "console" data and both terminals said "not available in browser preview" — i.e. `window.helder` was undefined in the actual app, so the renderer fell back to mock mode.
|
|
||||||
- **Cause:** electron-vite built the preload as `out/preload/index.mjs`, but `main/index.ts` loaded `../preload/index.js` (wrong extension) → Electron silently loaded no preload → no bridge.
|
|
||||||
- **Fix:** build the preload as **CommonJS `index.cjs`** (loads synchronously before the page, so `contextBridge` is exposed by the time React mounts) and point `main` at `../preload/index.cjs`. Verified the built `index.cjs` exposes `helder` and main references it. **Re-run `npm run dev` to confirm the live window now loads the real project + terminals.**
|
|
||||||
|
|
||||||
## Final state (all green)
|
|
||||||
- `npm run typecheck` ✓ · `npm run lint` ✓ (0 errors, 8 intentional warnings) · `npm test` ✓ (51) · `npm run build` ✓ · `npm run pack` ✓ (`Helder.app` with icon + unpacked native binaries)
|
|
||||||
- **DESIGN.md is now fully implemented** (the only intentional exception is the separate Go-to-File overlay, replaced by ⌘P→unified-search per the resolved decision).
|
|
||||||
- Everything left **uncommitted** for your review (laptop is read-only on git).
|
|
||||||
- The only thing not exercisable in this headless session remains the live Electron GUI — `npm run dev` to see it.
|
|
||||||
@@ -8,9 +8,12 @@ files:
|
|||||||
- out/**
|
- out/**
|
||||||
- package.json
|
- package.json
|
||||||
# Native / spawned binaries must live outside the asar to load at runtime.
|
# Native / spawned binaries must live outside the asar to load at runtime.
|
||||||
|
# The rg binary ships in a platform package (@vscode/ripgrep-<platform>-<arch>),
|
||||||
|
# so that must be unpacked too — not just the @vscode/ripgrep shim.
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- '**/node_modules/node-pty/**'
|
- '**/node_modules/node-pty/**'
|
||||||
- '**/node_modules/@vscode/ripgrep/**'
|
- '**/node_modules/@vscode/ripgrep/**'
|
||||||
|
- '**/node_modules/@vscode/ripgrep-*/**'
|
||||||
mac:
|
mac:
|
||||||
category: public.app-category.developer-tools
|
category: public.app-category.developer-tools
|
||||||
target:
|
target:
|
||||||
|
|||||||
@@ -49,19 +49,22 @@ const THEME_TEMPLATE = `/* Helder theme — custom CSS applied OVER the built-in
|
|||||||
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
/** Recently-opened files, newest first. Local machine state — git-ignored. */
|
||||||
const RECENT_FILE = 'recent.json'
|
const RECENT_FILE = 'recent.json'
|
||||||
const MAX_RECENT = 100
|
const MAX_RECENT = 100
|
||||||
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit)\n${RECENT_FILE}\n`
|
// The whole .helder folder is local, machine-specific state — ignore all of it.
|
||||||
|
const GITIGNORE_BODY = `# Helder — local, machine-specific state (do not commit).\n*\n`
|
||||||
|
|
||||||
let current: HelderConfig = DEFAULTS
|
let current: HelderConfig = DEFAULTS
|
||||||
let themeCss = ''
|
let themeCss = ''
|
||||||
|
|
||||||
/** Create `.helder/.gitignore` (ignoring recent.json) only when it's missing. */
|
/** Ensure `.helder/.gitignore` ignores the entire folder; (re)write it when the
|
||||||
|
* file is missing or out of date (e.g. upgrading from the old recent-only one). */
|
||||||
async function ensureGitignore(dir: string): Promise<void> {
|
async function ensureGitignore(dir: string): Promise<void> {
|
||||||
const path = join(dir, '.gitignore')
|
const path = join(dir, '.gitignore')
|
||||||
try {
|
try {
|
||||||
await readFile(path, 'utf8')
|
if ((await readFile(path, 'utf8')) === GITIGNORE_BODY) return
|
||||||
} catch {
|
} catch {
|
||||||
await writeFile(path, GITIGNORE_BODY)
|
/* missing — fall through to write */
|
||||||
}
|
}
|
||||||
|
await writeFile(path, GITIGNORE_BODY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecent(root: string): Promise<string[]> {
|
export async function getRecent(root: string): Promise<string[]> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
import { readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { join, relative, sep } from 'node:path'
|
import { join, relative, sep } from 'node:path'
|
||||||
import { listFiles } from './search-service'
|
import { listFiles } from './search-service'
|
||||||
|
|
||||||
@@ -120,6 +120,13 @@ export async function writeProjectFile(root: string, rel: string, content: strin
|
|||||||
await writeFile(join(root, rel), content, 'utf8')
|
await writeFile(join(root, rel), content, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete a project file or folder (relative path). Stays inside the project root. */
|
||||||
|
export async function deleteProjectFile(root: string, rel: string): Promise<void> {
|
||||||
|
const target = join(root, rel)
|
||||||
|
if (relative(root, target).startsWith('..')) return // guard against escaping the root
|
||||||
|
await rm(target, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory content index of all (small, text) files — powers content viewing.
|
* In-memory content index of all (small, text) files — powers content viewing.
|
||||||
* Primary: read the rg file list; fallback: walk. Capped for large repos.
|
* Primary: read the rg file list; fallback: walk. Capped for large repos.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { join, sep } from 'node:path'
|
|||||||
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 { readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
import { deleteProjectFile, readAll, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
||||||
import { commit, discard, load, stage, unstage } from './git-service'
|
import { commit, discard, load, 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'
|
||||||
@@ -104,6 +104,8 @@ function registerIpc(): void {
|
|||||||
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
ipcMain.handle('fs:files', () => { const r = getRoot(); return r ? readAll(r) : {} })
|
||||||
ipcMain.handle('fs:read', (_e, rel: string) => { const r = getRoot(); return r ? readProjectFile(r, rel) : '' })
|
ipcMain.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) })
|
ipcMain.handle('fs:write', (_e, rel: string, content: string) => { const r = getRoot(); if (r) return writeProjectFile(r, rel, content) })
|
||||||
|
ipcMain.handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
|
||||||
|
ipcMain.handle('shell:reveal', (_e, rel: string) => { const r = getRoot(); if (r) shell.showItemInFolder(join(r, rel)) })
|
||||||
|
|
||||||
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
ipcMain.handle('git:load', () => { const r = getRoot(); return r ? load(r) : null })
|
||||||
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
|
ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) })
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const api = {
|
|||||||
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),
|
||||||
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),
|
||||||
|
},
|
||||||
|
|
||||||
|
shell: {
|
||||||
|
reveal: (path: string): void => { ipcRenderer.invoke('shell:reveal', path) },
|
||||||
},
|
},
|
||||||
|
|
||||||
git: {
|
git: {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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 { ContextMenu, HelpModal, HistoryModal, PassPopup, SearchModal, Toasts } from './overlays'
|
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, PassPopup, 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 { FileNode, GitStatus } from './types'
|
||||||
@@ -95,6 +95,7 @@ export function App(): React.ReactElement {
|
|||||||
const [splitFor, setSplitFor] = useState<string | null>(null)
|
const [splitFor, setSplitFor] = useState<string | null>(null)
|
||||||
const [commitMsg, setCommitMsg] = useState('')
|
const [commitMsg, setCommitMsg] = useState('')
|
||||||
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
|
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string } | null>(null)
|
||||||
|
const [confirm, setConfirm] = useState<{ title: string; body?: string; confirmLabel: string; onConfirm: () => void } | null>(null)
|
||||||
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
// Brief full-screen "branch - repository" flash whenever the window gains focus
|
||||||
// (handy when juggling several project windows).
|
// (handy when juggling several project windows).
|
||||||
const [showFlash, setShowFlash] = useState(false)
|
const [showFlash, setShowFlash] = useState(false)
|
||||||
@@ -260,6 +261,35 @@ export function App(): React.ReactElement {
|
|||||||
setCommitMsg('')
|
setCommitMsg('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealInFinder(path: string): void {
|
||||||
|
window.helder?.shell.reveal(path)
|
||||||
|
}
|
||||||
|
// Delete a file or folder from disk, then drop anything inside it from the UI.
|
||||||
|
async function deleteEntry(path: string, isDir: boolean): Promise<void> {
|
||||||
|
const bridge = window.helder
|
||||||
|
if (bridge) {
|
||||||
|
try { await bridge.fs.delete(path) } catch { toast('Delete failed', path); return }
|
||||||
|
}
|
||||||
|
const inside = (p: string): boolean => p === path || (isDir && p.startsWith(path + '/'))
|
||||||
|
setHistory((h) => h.filter((p) => !inside(p)))
|
||||||
|
setBuffers((b) => {
|
||||||
|
const keys = Object.keys(b).filter(inside)
|
||||||
|
if (!keys.length) return b
|
||||||
|
const n = { ...b }; keys.forEach((k) => delete n[k]); return n
|
||||||
|
})
|
||||||
|
if (active && inside(active)) setActive(null)
|
||||||
|
actions.refresh()
|
||||||
|
toast(isDir ? 'Deleted folder' : 'Deleted file', path)
|
||||||
|
}
|
||||||
|
function askDelete(path: string, isDir: boolean): void {
|
||||||
|
setConfirm({
|
||||||
|
title: isDir ? 'Delete folder?' : 'Delete file?',
|
||||||
|
body: isDir ? `${path}/ and everything inside it will be permanently deleted.` : `${path} will be permanently deleted.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
onConfirm: () => deleteEntry(path, isDir),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const defaultMode: Mode = proj.config.git.defaultDiffMode
|
const defaultMode: Mode = proj.config.git.defaultDiffMode
|
||||||
function stageGuarded(p: string): void {
|
function stageGuarded(p: string): void {
|
||||||
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
|
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
|
||||||
@@ -360,7 +390,12 @@ export function App(): React.ReactElement {
|
|||||||
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
|
items.push({ icon: Icon.discard(), label: 'Discard changes', onClick: () => doDiscard(target.path) })
|
||||||
}
|
}
|
||||||
items.push({ icon: Icon.file(), label: 'Open file', onClick: () => openFile(target.path) })
|
items.push({ icon: Icon.file(), label: 'Open file', onClick: () => openFile(target.path) })
|
||||||
items.push({ icon: Icon.reveal(), label: 'Reveal in Explorer', onClick: () => reveal(target.path) })
|
}
|
||||||
|
// Show in Finder + delete — for explorer files and folders (not git rows).
|
||||||
|
if (isDir || target.kind === 'file') {
|
||||||
|
items.push({ sep: true })
|
||||||
|
items.push({ icon: Icon.finder(), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
|
||||||
|
items.push({ icon: Icon.trash(), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
|
||||||
}
|
}
|
||||||
setMenu({ x: e.clientX, y: e.clientY, note: ref, items })
|
setMenu({ x: e.clientX, y: e.clientY, note: ref, items })
|
||||||
}
|
}
|
||||||
@@ -425,6 +460,7 @@ export function App(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent): void {
|
function onKey(e: KeyboardEvent): void {
|
||||||
if (!proj.root) return // launcher screen owns its own keyboard
|
if (!proj.root) return // launcher screen owns its own keyboard
|
||||||
|
if (confirm) return // the confirm dialog owns the keyboard while open
|
||||||
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')
|
||||||
@@ -450,6 +486,8 @@ export function App(): React.ReactElement {
|
|||||||
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
||||||
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).
|
||||||
|
else if (meta && e.key.toLowerCase() === 'd') { e.preventDefault(); if (active) askDelete(active, false) }
|
||||||
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
else if (meta && e.key.toLowerCase() === 'm') { e.preventDefault(); cycleMode() }
|
||||||
// ⌘→ with a text selection passes that selection to the agent (else native nav).
|
// ⌘→ with a text selection passes that selection to the agent (else native nav).
|
||||||
else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() }
|
else if (meta && e.key === 'ArrowRight') { if (passSelection()) e.preventDefault() }
|
||||||
@@ -472,7 +510,7 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection])
|
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm])
|
||||||
|
|
||||||
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
|
||||||
|
|
||||||
@@ -575,6 +613,7 @@ export function App(): React.ReactElement {
|
|||||||
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
{overlay === 'search' && <SearchModal initialQuery={searchInit} onOpen={openFile} onOpenAt={(p, n) => openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} />}
|
||||||
{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 === 'help' && <HelpModal onClose={() => setOverlay(null)} />}
|
{overlay === 'help' && <HelpModal onClose={() => setOverlay(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} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
|
|||||||
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>),
|
||||||
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>),
|
||||||
|
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>),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
export const Chevron = ({ open }: { open: boolean }): React.ReactElement => (
|
||||||
|
|||||||
4
src/renderer/src/env.d.ts
vendored
4
src/renderer/src/env.d.ts
vendored
@@ -23,6 +23,10 @@ interface HelderBridge {
|
|||||||
files: () => Promise<Record<string, string>>
|
files: () => Promise<Record<string, string>>
|
||||||
read: (path: string) => Promise<string>
|
read: (path: string) => Promise<string>
|
||||||
write: (path: string, content: string) => Promise<void>
|
write: (path: string, content: string) => Promise<void>
|
||||||
|
delete: (path: string) => Promise<void>
|
||||||
|
}
|
||||||
|
shell: {
|
||||||
|
reveal: (path: string) => void
|
||||||
}
|
}
|
||||||
git: {
|
git: {
|
||||||
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
load: () => Promise<{ branch: string; changes: GitChangeRaw[] } | null>
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
|||||||
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
||||||
{ 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: ['⌘', 'O'], label: 'Open a project folder' },
|
{ keys: ['⌘', 'O'], label: 'Open a project folder' },
|
||||||
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
{ keys: ['Esc'], label: 'Close an overlay / split view' },
|
||||||
]
|
]
|
||||||
@@ -307,6 +308,40 @@ export function HelpModal({ onClose }: { onClose: () => void }): React.ReactElem
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Generic confirm dialog — ↵ confirms, Esc cancels. Listens in capture phase so
|
||||||
|
* it owns the keyboard while open. */
|
||||||
|
export function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }: {
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
danger?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}): React.ReactElement {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onConfirm(); onClose() }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey, true)
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div className="scrim" onMouseDown={onClose}>
|
||||||
|
<div className="confirm-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="cf-title">{title}</div>
|
||||||
|
{body && <div className="cf-body">{body}</div>}
|
||||||
|
<div className="cf-actions">
|
||||||
|
<button className="cf-btn" onClick={onClose}>Cancel <kbd>esc</kbd></button>
|
||||||
|
<button className={'cf-btn cf-yes' + (danger ? ' danger' : '')} onClick={() => { onConfirm(); onClose() }} autoFocus>
|
||||||
|
{confirmLabel ?? 'Confirm'} <kbd>↵</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
export function ContextMenu({ menu, onClose }: { menu: Menu | null; onClose: () => void }): React.ReactElement | null {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -478,6 +478,21 @@ body {
|
|||||||
/* title-bar icon-only button (help ?) */
|
/* title-bar icon-only button (help ?) */
|
||||||
.tb-icon { padding:5px 7px; }
|
.tb-icon { padding:5px 7px; }
|
||||||
|
|
||||||
|
/* confirm dialog (delete, etc.) */
|
||||||
|
.confirm-modal { width:420px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:12px; box-shadow:0 24px 70px rgba(0,0,0,.55); padding:18px 20px; }
|
||||||
|
.cf-title { font-size:14px; font-weight:600; color:var(--fg-0); }
|
||||||
|
.cf-body { margin-top:8px; font-size:12.5px; line-height:1.45; color:var(--fg-2); font-family:var(--mono); word-break:break-all; }
|
||||||
|
.cf-actions { margin-top:18px; display:flex; justify-content:flex-end; gap:9px; }
|
||||||
|
.cf-btn { display:flex; align-items:center; gap:7px; font-size:12.5px; color:var(--fg-1); background:var(--bg-2); border:1px solid var(--border-2); border-radius:7px; padding:7px 13px; cursor:pointer; }
|
||||||
|
.cf-btn:hover { background:var(--hover); color:var(--fg-0); }
|
||||||
|
.cf-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
|
||||||
|
.cf-yes { background:var(--accent); color:#201608; border-color:transparent; font-weight:600; }
|
||||||
|
.cf-yes:hover { background:#f6b35f; color:#201608; }
|
||||||
|
.cf-yes kbd { color:#201608; border-color:rgba(0,0,0,.25); }
|
||||||
|
.cf-yes.danger { background:var(--del); color:#fff; }
|
||||||
|
.cf-yes.danger:hover { background:#e8797a; }
|
||||||
|
.cf-yes.danger kbd { color:#fff; border-color:rgba(255,255,255,.4); }
|
||||||
|
|
||||||
/* search: active result column + file-name selection */
|
/* search: active result column + file-name selection */
|
||||||
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; }
|
.sc-head .col-kbd { margin-left:auto; font-family:var(--mono); font-size:9.5px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 4px; opacity:.55; }
|
||||||
.sc-left.active .sc-head, .sc-right.active .sc-head { color:var(--accent); }
|
.sc-left.active .sc-head, .sc-right.active .sc-head { color:var(--accent); }
|
||||||
|
|||||||
45
sync_helder.sh
Executable file
45
sync_helder.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# sync_helder.sh — build Helder (arm64), install it into ~/Applications, and
|
||||||
|
# drop a stable-named Helder.dmg alongside it.
|
||||||
|
#
|
||||||
|
# ./sync_helder.sh
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run from the repo root (where this script lives), whatever the caller's cwd is.
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APPS_DIR="$HOME/Applications"
|
||||||
|
APP_DEST="$APPS_DIR/Helder.app"
|
||||||
|
DMG_DEST="$APPS_DIR/Helder.dmg"
|
||||||
|
|
||||||
|
echo "▶ Building Helder (arm64)…"
|
||||||
|
npm run dist:mac
|
||||||
|
|
||||||
|
# Locate the freshly built artifacts.
|
||||||
|
APP_SRC="dist/mac-arm64/Helder.app"
|
||||||
|
DMG_SRC="$(ls -t dist/Helder-*-arm64.dmg 2>/dev/null | head -n1 || true)"
|
||||||
|
|
||||||
|
[ -d "$APP_SRC" ] || { echo "✗ Build output not found: $APP_SRC"; exit 1; }
|
||||||
|
[ -n "$DMG_SRC" ] && [ -f "$DMG_SRC" ] || { echo "✗ No built .dmg found in dist/"; exit 1; }
|
||||||
|
|
||||||
|
mkdir -p "$APPS_DIR"
|
||||||
|
|
||||||
|
# Quit a running copy so we can replace the bundle cleanly.
|
||||||
|
osascript -e 'tell application "Helder" to quit' >/dev/null 2>&1 || true
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
echo "▶ Installing app → $APP_DEST"
|
||||||
|
rm -rf "$APP_DEST"
|
||||||
|
cp -R "$APP_SRC" "$APP_DEST"
|
||||||
|
# Unsigned ad-hoc build: clear the quarantine flag so it launches without the
|
||||||
|
# Gatekeeper "unidentified developer" prompt.
|
||||||
|
xattr -dr com.apple.quarantine "$APP_DEST" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "▶ Copying installer → $DMG_DEST"
|
||||||
|
cp -f "$DMG_SRC" "$DMG_DEST"
|
||||||
|
|
||||||
|
echo "✓ Done."
|
||||||
|
echo " • Installed: $APP_DEST (launch from Spotlight)"
|
||||||
|
echo " • Installer: $DMG_DEST (renamed from $(basename "$DMG_SRC"))"
|
||||||
Reference in New Issue
Block a user