Compare commits

...

2 Commits

Author SHA1 Message Date
1b212e2fb6 color gray on the git test files + dock menu
Some checks failed
CI / check (push) Has been cancelled
2026-08-17 11:21:52 +02:00
3a941480a9 commit 2026-08-17 10:43:38 +02:00
20 changed files with 455 additions and 52 deletions

View File

@@ -47,6 +47,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for
- **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text.
- **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync.
- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here).
- **Word wrap swaps the buffer's layout, it is not just a CSS switch** (`editor.wordWrap`: `markdown` default / `on` / `off`). Unwrapped, `CodeEditor` renders one highlighted blob and a separate gutter column that follows the scroll. Wrapped, a fixed 20px-per-line gutter no longer lines up, so each line becomes a `.ce-line` block and the number is a CSS counter on `::before` — that keeps it on the first visual row and leaves folded rows blank. Two things bite here: `.ce-inner` must drop `width:max-content` (and `.editor` its `max-content` grid track), or a folded line still measures its full unfolded width and never breaks; and the textarea and the `<pre>` must fold identically — same width, padding, font, `white-space:pre-wrap`, `overflow-wrap:break-word` — or the caret drifts off the text. Split view stays unwrapped on purpose: its panes align row by row.
- **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending.
- **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes.
- **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery.

View File

@@ -141,6 +141,7 @@ Shared rules: red always means removed or changed-from, green always means added
- A file with no changes opens directly in a normal editable view with no view-mode control, since there is nothing to diff.
- Editing follows the editor settings: indentation, indent width, trim trailing whitespace, insert final newline, word wrap, indent guides, whitespace rendering, and active-line highlight, all read from configuration (section 10).
- **Word wrap** follows `editor.wordWrap`: `markdown` (default), `on`, or `off`. When it is on, a line too wide for the pane folds onto the next row instead of scrolling sideways. Only the first row of a folded line carries a line number; the rows below it stay blank. Split view never wraps, because its two panes align row by row.
### Right-click in code

View File

@@ -11,10 +11,12 @@ import { join } from 'node:path'
* Effective value = config.json over config.default.json, merged key by key.
*/
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
@@ -23,7 +25,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: { shell: null },

View File

@@ -106,12 +106,22 @@ function installAppHooks(): void {
contents.on('preload-error', (_ev, preloadPath, error) => {
logger.error('preload', 'preload script threw — window.helder will be undefined (mock-data fallback)', error, { preloadPath })
})
let sawRoLoop = false
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
// "ResizeObserver loop …" is a browser layout notice, not a fault, and it
// fires once per frame — a window drag would bury everything else. The
// renderer suppresses its own repeats the same way (see log.ts).
if (d.message.startsWith('ResizeObserver loop')) {
if (sawRoLoop) return
sawRoLoop = true
log('warn', 'console', d.message + ' (browser layout notice; repeats suppressed)', { source: d.source, line: d.line })
return
}
log(d.level >= 3 ? 'error' : 'warn', 'console', d.message, { source: d.source, line: d.line })
})
})

View File

@@ -1,4 +1,4 @@
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, relative, sep } from 'node:path'
import { listFiles, rgAvailable } from './search-service'
@@ -261,6 +261,29 @@ export async function createProjectDir(root: string, rel: string): Promise<void>
await mkdir(target, { recursive: true })
}
/**
* Rename a project file or folder. `rel` is the current relative path, `name`
* the new basename (no slashes — a rename stays in the same folder). Returns the
* new relative path. Refuses to escape the project root and throws if the target
* name is already taken, so a rename never clobbers an existing file.
*/
export async function renameProjectEntry(root: string, rel: string, name: string): Promise<string> {
const clean = name.trim().replace(/\/+$/, '')
if (!clean || clean.includes('/') || clean === '.' || clean === '..') throw new Error('invalid name')
const from = join(root, rel)
const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
const next = parent ? `${parent}/${clean}` : clean
const to = join(root, next)
if (relative(root, from).startsWith('..') || relative(root, to).startsWith('..')) throw new Error('outside project root')
if (from === to) return rel
// Case-only renames (foo.md → Foo.md) hit an existing path on macOS' case
// insensitive filesystem, so only guard when the name really differs.
const sameName = from.toLowerCase() === to.toLowerCase()
if (!sameName && await stat(to).catch(() => null)) throw new Error('name already exists')
await rename(from, to)
return next
}
/** 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)

View File

@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'
import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
import { watch, type FSWatcher } from 'chokidar'
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, writeProjectFile } from './fs-service'
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readImageDataUrl, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from './fs-service'
import { commit, discard, load, push, stage, unstage } from './git-service'
import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
@@ -122,7 +122,7 @@ async function openFolderFlow(win: BrowserWindow | null): Promise<boolean> {
startWatcher()
startConfigWatcher()
startGitWatcher()
syncWindowTitle()
syncProjectChrome()
return true
}
@@ -196,8 +196,17 @@ function buildAppMenu(): Menu {
/** 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. */
/**
* macOS reads the Dock tile's name from the bundle's CFBundleName at launch, so
* every Helder process shows the same "Helder" tooltip — `app.setName()` moves
* the menu-bar name and the paths, but never the Dock label. The open project is
* therefore named in the Dock *menu* instead: a disabled first item, so a
* right-click tells the two tiles apart.
*/
function buildDockMenu(): Menu {
const name = getName()
return Menu.buildFromTemplate([
...(name ? [{ label: name, enabled: false }, { type: 'separator' as const }] : []),
{ label: 'New Window', click: () => spawnInstance() },
])
}
@@ -270,7 +279,7 @@ function registerIpc(): void {
setRoot(path)
const r = getRoot()
if (r) { await resolveConfig(r); startWatcher(); startConfigWatcher(); startGitWatcher() }
syncWindowTitle()
syncProjectChrome()
broadcast('project:changed')
return { root: getRoot(), name: getName() }
})
@@ -284,6 +293,7 @@ function registerIpc(): void {
handle('fs:delete', (_e, rel: string) => { const r = getRoot(); if (r) return deleteProjectFile(r, rel) })
handle('fs:create', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectFile(r, rel) })
handle('fs:mkdir', (_e, rel: string) => { const r = getRoot(); if (r) return createProjectDir(r, rel) })
handle('fs:rename', (_e, rel: string, name: string) => { const r = getRoot(); return r ? renameProjectEntry(r, rel, name) : 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) })
@@ -337,11 +347,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 {
/** The window title and the Dock menu both carry the open project's folder name
* (the title falls back to the app name when nothing is open). Push both after a
* project change. */
function syncProjectChrome(): void {
const title = getName() || 'Helder'
for (const w of BrowserWindow.getAllWindows()) w.setTitle(title)
app.dock?.setMenu(buildDockMenu())
}
function createWindow(): void {

View File

@@ -30,6 +30,7 @@ const api = {
delete: (path: string): Promise<void> => ipcRenderer.invoke('fs:delete', path),
create: (path: string): Promise<void> => ipcRenderer.invoke('fs:create', path),
mkdir: (path: string): Promise<void> => ipcRenderer.invoke('fs:mkdir', path),
rename: (path: string, name: string): Promise<string> => ipcRenderer.invoke('fs:rename', path, name),
},
shell: {

View File

@@ -100,6 +100,7 @@ export function App(): React.ReactElement {
const [passPopup, setPassPopup] = useState<{ x: number; y: number; ref: string; code?: string } | null>(null)
const [newFilePopup, setNewFilePopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [newFolderPopup, setNewFolderPopup] = useState<{ x: number; y: number; dir: string } | null>(null)
const [renamePopup, setRenamePopup] = useState<{ x: number; y: number; path: string; isDir: boolean } | 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
// (handy when juggling several project windows).
@@ -196,6 +197,9 @@ export function App(): React.ReactElement {
// turns it back on (and immediately re-fits).
const [autoResize, setAutoResize] = useState(true)
const [showHidden, setShowHidden] = useState(false)
// Col A (Git) visibility. On by default; the title-bar GIT toggle hides the
// whole column and its splitter, and the editor takes the free width.
const [showGit, setShowGit] = useState(true)
const [gitW, setGitW] = useState(() => Math.round(window.innerWidth * 0.15))
const [treeW, setTreeW] = useState(() => Math.round(window.innerWidth * 0.15))
const [rightW, setRightW] = useState(() => Math.round(window.innerWidth * 0.3))
@@ -219,6 +223,11 @@ export function App(): React.ReactElement {
return () => window.removeEventListener('resize', apply)
}, [focusZone, autoResize])
// A hidden Col A must not keep the focus ring or the arrow-key cursor.
useEffect(() => {
if (!showGit) setActivePanel((p) => (p === 'git' ? null : p))
}, [showGit])
// Flat, render-order lists of the rows in Git (A) and Explorer (B) — the targets
// for arrow-key navigation. Git: staged group then changes group. Tree: the
// currently-visible nodes (honouring expansion + the hidden-files toggle).
@@ -467,6 +476,33 @@ export function App(): React.ReactElement {
toast('Created folder', rel)
}
// Rename a file or folder on disk, then carry every path-keyed piece of UI
// state (tabs, buffers, history, open folders, cursor) over to the new path —
// otherwise an open file would keep pointing at a name that no longer exists.
async function renameEntry(path: string, isDir: boolean, name: string): Promise<void> {
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
let next = (parent ? parent + '/' : '') + name
const bridge = window.helder
if (bridge) {
try { next = await bridge.fs.rename(path, name) } catch (e) { rlog.error('fs', 'rename failed', e, { path, name }); toast('Rename failed', name); return }
}
if (next === path) return
// Folders move everything under them, so remap by prefix as well.
const remap = (p: string): string => (p === path ? next : (isDir && p.startsWith(path + '/') ? next + p.slice(path.length) : p))
const remapKeys = <T,>(m: Record<string, T>): Record<string, T> =>
Object.fromEntries(Object.entries(m).map(([k, v]) => [remap(k), v]))
setHistory((h) => h.map(remap))
setBuffers(remapKeys)
setTabMode(remapKeys)
setTabSide(remapKeys)
setOpenDirs((d) => new Set([...d].map(remap)))
setActive((a) => (a ? remap(a) : a))
setSplitFor((s) => (s ? remap(s) : s))
setCursor((c) => (c ? { ...c, path: remap(c.path) } : c))
actions.refresh()
toast(isDir ? 'Renamed folder' : 'Renamed file', next)
}
function askDelete(path: string, isDir: boolean): void {
setConfirm({
title: isDir ? 'Delete folder?' : 'Delete file?',
@@ -570,6 +606,18 @@ export function App(): React.ReactElement {
],
}
}
// Empty space under the tree: the project root. Deliberately short — no
// rename/delete, because "delete" here would mean the whole project.
if (target.kind === 'root') {
return {
note: ((proj.root ?? '').split('/').pop() || 'project') + '/',
path: '',
items: [
{ primary: true, icon: Icon.file({ style: { color: 'var(--add)' } }), label: 'New file', onClick: () => setNewFilePopup({ x, y, dir: '' }) },
{ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder('') },
],
}
}
const isDir = target.kind === 'dir'
const ref = isDir ? target.path + '/' : target.path
const name = target.path.split('/').pop() as string
@@ -597,9 +645,10 @@ export function App(): React.ReactElement {
items.push({ icon: Icon.diff({ style: { color: 'var(--ren)' } }), label: 'Open diff', onClick: () => openFile(target.path, { diff: true }) })
items.push({ icon: Icon.discard({ style: { color: 'var(--del)' } }), label: 'Discard changes', onClick: () => doDiscard(target.path) })
}
// Show in Finder + delete — for explorer files and folders (not git rows).
// Rename + 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.pencil({ style: { color: 'var(--ren)' } }), label: isDir ? 'Rename folder' : 'Rename file', onClick: () => setRenamePopup({ x, y, path: target.path, isDir }) })
items.push({ icon: Icon.finder({ style: { color: 'var(--mod)' } }), label: 'Show in Finder', onClick: () => revealInFinder(target.path) })
items.push({ icon: Icon.trash({ style: { color: 'var(--del)' } }), label: isDir ? 'Delete folder' : 'Delete file', onClick: () => askDelete(target.path, isDir) })
}
@@ -832,6 +881,10 @@ export function App(): React.ReactElement {
else if (meta && e.code === 'Period') {
e.preventDefault(); setShowHidden((v) => !v)
}
// ⌘G shows or hides the Source Control column (Col A).
else if (meta && e.key.toLowerCase() === 'g') {
e.preventDefault(); setShowGit((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
@@ -933,6 +986,10 @@ export function App(): React.ReactElement {
title="Project note (.notes.txt) — kept next to this project">
{Icon.note({ width: 13, height: 13 })} Note <kbd>N</kbd>
</button>
<button className={'tb-btn tb-toggle' + (showGit ? ' on' : '')} onClick={() => setShowGit((v) => !v)}
title={showGit ? 'Source Control column: shown. Click to hide it.' : 'Source Control column: hidden. Click to show it.'}>
{Icon.branch()} Git <kbd>G</kbd>
</button>
<button className="tb-btn tb-icon" onClick={() => setOverlay('help')} title="Keyboard shortcuts">{Icon.help()}</button>
</div>
</div>
@@ -951,15 +1008,17 @@ export function App(): React.ReactElement {
{/* workbench */}
<div className="workbench">
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}>
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
{showGit && (<>
<div className={'col' + (activePanel === 'git' ? ' panel-active' : '') + flashClass} style={{ width: gitW, flex: '0 0 ' + gitW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('git'); syncGitSel(e.target) }}>
<GitPanel branch={proj.branch} changes={proj.changes} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} activeSide={activeSide} ctxPath={menu?.path ?? null}
kbdId={activePanel === 'git' ? gitSelRow?.id ?? null : null} showDir={gitW > 300} />
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
</>)}
<div className={'col' + (activePanel === 'tree' ? ' panel-active' : '') + flashClass} style={{ width: treeW, flex: '0 0 ' + treeW + 'px' }}
onMouseDownCapture={(e) => { setActivePanel('tree'); syncTreeSel(e.target) }}>
@@ -983,7 +1042,7 @@ export function App(): React.ReactElement {
</div>
<Splitter onDelta={(dx) => { setAutoResize(false); setRightW((w) => {
// grow until the editor would drop below ~280px (rather than a fixed cap)
const max = Math.max(280, window.innerWidth - gitW - treeW - 280)
const max = Math.max(280, window.innerWidth - (showGit ? gitW : 0) - treeW - 280)
return clamp(w - dx, 280, max)
}) }} />
@@ -1007,6 +1066,11 @@ export function App(): React.ReactElement {
{newFolderPopup && <NamePopup x={newFolderPopup.x} y={newFolderPopup.y} dir={newFolderPopup.dir} kind="folder"
onConfirm={(name) => { createFolder(newFolderPopup.dir, name); setNewFolderPopup(null) }}
onCancel={() => setNewFolderPopup(null)} />}
{renamePopup && <NamePopup x={renamePopup.x} y={renamePopup.y}
dir={renamePopup.path.includes('/') ? renamePopup.path.slice(0, renamePopup.path.lastIndexOf('/')) : ''}
kind={renamePopup.isDir ? 'folder' : 'file'} mode="rename" initial={renamePopup.path.split('/').pop() as string}
onConfirm={(name) => { renameEntry(renamePopup.path, renamePopup.isDir, name); setRenamePopup(null) }}
onCancel={() => setRenamePopup(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 === '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)} />}

View File

@@ -26,6 +26,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
note: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><path d="M3.5 2.5h9v11h-9v-11z" stroke="currentColor" strokeWidth="1.2" fill="none" /><path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
help: (p) => (<svg width="14" height="14" viewBox="0 0 16 16" fill="none" {...p}><circle cx="8" cy="8" r="6.2" stroke="currentColor" strokeWidth="1.3" /><path d="M6.3 6.2a1.7 1.7 0 1 1 2.3 1.6c-.5.25-.8.6-.8 1.2v.3" stroke="currentColor" strokeWidth="1.3" fill="none" strokeLinecap="round" /><circle cx="8" cy="11.4" r=".75" fill="currentColor" /></svg>),
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
pencil: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M2.5 11.2 11 2.7a1.6 1.6 0 0 1 2.3 2.3l-8.5 8.5-3 .7.7-3z" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinejoin="round" /><path d="M9.6 4.1l2.3 2.3" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /></svg>),
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
@@ -59,7 +60,8 @@ export function FileIcon({ path }: { path: string }): React.ReactElement {
export type OpenFile = (path: string, opts?: { diff?: boolean; line?: number; side?: DiffSide }) => void
export interface ContextTarget {
path: string
kind: 'editor' | 'dir' | 'file' | 'git'
/** 'root' = the empty space under the tree, i.e. the project folder itself. */
kind: 'editor' | 'dir' | 'file' | 'git' | 'root'
staged?: boolean
sel?: { start: number; end: number }
line?: number
@@ -68,6 +70,12 @@ export interface ContextTarget {
export type OnContext = (e: React.MouseEvent, target: ContextTarget) => void
/* ============ Git / Source Control panel ============ */
/** True for files under the project root's `tests/` folder. The git list dims these. */
function isTestFile(path: string): boolean {
return path.startsWith('tests/')
}
function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, onContext, onToggleStage }: {
c: Change
activePath: string | null
@@ -87,7 +95,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, showDir, onOpen, on
const dirShown = showDir && !!dir
const isActive = activePath === c.path && (!activeSide || activeSide === side)
return (
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '')}
<div className={'git-row' + (isActive ? ' active' : '') + (ctxPath === c.path ? ' ctx' : '') + (kbdId === c.id ? ' kbd' : '') + (isTestFile(c.path) ? ' test' : '')}
data-row-id={c.id}
onClick={() => onOpen(c.path, { diff: true, side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
@@ -254,7 +262,9 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
}): React.ReactElement {
return (
<Fragment>
<div className="tree-body">
{/* Right-clicking the empty space under the rows targets the project root.
Row menus stop propagation, so this only fires on the bare background. */}
<div className="tree-body" onContextMenu={(e) => onContext(e, { path: '', kind: 'root' })}>
<TreeNode node={tree} depth={0} openDirs={openDirs} toggleDir={toggleDir}
onOpen={onOpen} onContext={onContext} activePath={activePath} ctxPath={ctxPath} kbdPath={kbdPath} changeMap={changeMap} committed={committed} showHidden={showHidden} />
</div>

View File

@@ -19,18 +19,34 @@ function climbToLine(node: Node | null): HTMLElement | null {
}
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing. */
function CodeEditor({ path, text, lang, onChange, onContext }: {
* scroll-synced line-number gutter. Live highlighting while typing.
*
* Two layouts, picked by `wrap`:
* - no wrap: one highlighted blob, lines never break, gutter is a separate
* column that follows the vertical scroll.
* - wrap: long lines fold onto the next visual row, so a fixed 20px-per-line
* gutter no longer lines up. Each line becomes its own `.ce-line` block and
* the number is a CSS counter on `::before`, which keeps it on the first
* visual row and leaves the folded rows unnumbered. Highlighting is then per
* line (like the diff views), so multi-line tokens don't carry over. */
function CodeEditor({ path, text, lang, wrap, onChange, onContext }: {
path: string
text: string
lang: string | null
wrap: boolean
onChange: (text: string) => void
onContext: OnContext
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize
const html = useMemo(() => HL.hlText(text, lang), [text, lang])
const html = useMemo(
() => (wrap
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
: HL.hlText(text, lang) + '\n'),
[text, lang, wrap],
)
const count = useMemo(() => text.split('\n').length, [text])
function onScroll(): void {
@@ -39,14 +55,17 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
}
// The textarea is overflow-hidden under the scroller, so keep the caret line
// in view by scrolling the container ourselves (6px top pad, 20px line-height).
// When wrapping, a line can be taller than one row, so measure its block.
function ensureCaretVisible(ta: HTMLTextAreaElement): void {
const s = scrollRef.current
if (!s) return
const line = ta.value.slice(0, ta.selectionStart).split('\n').length - 1
const top = 6 + line * 20
const bottom = top + 20
if (top < s.scrollTop) s.scrollTop = top - 20
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + 20
const row = wrap ? (preRef.current?.children[line] as HTMLElement | undefined) : undefined
const top = row ? row.offsetTop : 6 + line * 20
const height = row ? row.offsetHeight : 20
const bottom = top + height
if (top < s.scrollTop) s.scrollTop = top - height
else if (bottom > s.scrollTop + s.clientHeight) s.scrollTop = bottom - s.clientHeight + height
}
/* Tab indents, it does not move focus out of the editor.
* Plain Tab on one line inserts spaces. Tab over a multi-line selection
@@ -103,22 +122,24 @@ function CodeEditor({ path, text, lang, onChange, onContext }: {
onContext(e, info)
}
return (
<div className="code-edit">
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>
{Array.from({ length: count }, (_, i) => <div key={i}>{i + 1}</div>)}
<div className={'code-edit' + (wrap ? ' wrap' : '')}>
{!wrap && (
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>
{Array.from({ length: count }, (_, i) => <div key={i}>{i + 1}</div>)}
</div>
</div>
</div>
)}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="ce-inner">
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap="off" style={{ tabSize }}
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
onChange={(e) => { onChange(e.target.value); ensureCaretVisible(e.target) }}
onKeyDown={onKeyDown}
onKeyUp={(e) => ensureCaretVisible(e.currentTarget)}
onClick={(e) => ensureCaretVisible(e.currentTarget)}
onContextMenu={handleContext} />
<pre className="ce-pre" aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html + '\n' }} />
<pre className="ce-pre" ref={preRef} aria-hidden style={{ tabSize }} dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
</div>
@@ -162,12 +183,14 @@ function ImageView({ path, onContext }: { path: string; onContext: OnContext }):
}
/* 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, wrap, cursor, selection, setCursor, setSelection, onContext }: {
cacheKey: string
path: string
lines: ViewLine[]
lang: string | null
showSign: boolean
/** Fold long lines onto the next row. The number column stays on row one. */
wrap: boolean
cursor: Cursor | null
selection: Selection | null
setCursor: (c: Cursor) => void
@@ -246,7 +269,7 @@ function PaneView({ cacheKey, path, lines, lang, showSign, cursor, selection, se
const sel = selection && selection.path === path ? selection : null
return (
<div className={'editor' + (showSign ? ' diff' : '')} onMouseUp={onMouseUp} onContextMenu={handleContext}>
<div className={'editor' + (showSign ? ' diff' : '') + (wrap ? ' wrap' : '')} onMouseUp={onMouseUp} onContextMenu={handleContext}>
{lines.map((l, i) => {
const no = l.no
const inSel = sel && no != null && no >= sel.start && no <= sel.end
@@ -320,6 +343,11 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
const hasDiff = !isImage && !!(change && diff)
const isMarkdown = lang === 'markdown'
const segments = segmentsFor(hasDiff, isMarkdown)
// Long lines fold instead of scrolling sideways. Default: Markdown only, where
// a paragraph is one very long line. Split view is left out on purpose — its
// two panes align row by row, and folding would pull them apart.
const wordWrap = PROJECT.config.editor.wordWrap
const wrap = wordWrap === 'on' || (wordWrap === 'markdown' && isMarkdown)
// Resolve the requested mode against what this file actually supports, so a
// mode carried over from another file (or an unchanged file asked for a diff
@@ -407,10 +435,10 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
) : emptyOriginal ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--add)' }}>No original version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file is new in the change.</div></div>
) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} onChange={onEdit} onContext={onContext} />
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} onChange={onEdit} onContext={onContext} />
) : (
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} wrap={wrap} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} />
)}
</div>

View File

@@ -28,6 +28,7 @@ interface HelderBridge {
delete: (path: string) => Promise<void>
create: (path: string) => Promise<void>
mkdir: (path: string) => Promise<void>
rename: (path: string, name: string) => Promise<string>
}
shell: {
reveal: (path: string) => void

View File

@@ -42,6 +42,16 @@ export const rlog = {
}
let installed = false
let sawRoLoop = false
/* "ResizeObserver loop completed with undelivered notifications" is a browser
* notice, not an exception: some observer resized its own target, so the layout
* needed a second pass. It fires once per frame, so a window drag alone can bury
* a real failure under a hundred lines. Keep the first one — it still points at
* a layout loop worth fixing — and drop the repeats. */
function isResizeObserverLoop(message: string): boolean {
return message.startsWith('ResizeObserver loop')
}
/** Hook the renderer's global failure paths. Call once, as early as possible. */
export function installErrorLogging(): void {
@@ -58,6 +68,12 @@ export function installErrorLogging(): void {
rlog.warn('resource', `failed to load ${el.tagName?.toLowerCase?.() ?? 'resource'}`, { url: el.src || el.href || '' })
return
}
if (isResizeObserverLoop(e.message || '')) {
if (sawRoLoop) return
sawRoLoop = true
rlog.warn('renderer', e.message, { note: 'browser layout notice; repeats suppressed' })
return
}
rlog.error('renderer', e.message || 'uncaught error', e.error, { source: e.filename, line: e.lineno, col: e.colno })
}, true)

View File

@@ -423,6 +423,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
{ keys: ['⌘', 'G'], label: 'Show or hide the Source Control column' },
{ keys: ['⌘', 'S'], label: 'Save the current file' },
{ keys: ['⌘', 'W'], label: 'Close the current file' },
{ keys: ['⌘', 'D'], label: 'Delete the current file (confirm)' },
@@ -637,19 +638,36 @@ export function PassPopup({ x, y, refStr, code, onConfirm, onCancel }: {
)
}
export function NamePopup({ x, y, dir, kind = 'file', onConfirm, onCancel }: {
/**
* One small popup for naming things: creating a file/folder inside `dir`, or
* renaming an existing entry (`mode="rename"`, prefilled with `initial`). A
* rename stays in the same folder, so the input is always a bare basename.
*/
export function NamePopup({ x, y, dir, kind = 'file', mode = 'create', initial = '', onConfirm, onCancel }: {
x: number
y: number
dir: string
kind?: 'file' | 'folder'
mode?: 'create' | 'rename'
initial?: string
onConfirm: (name: string) => void
onCancel: () => void
}): React.ReactElement {
const isFolder = kind === 'folder'
const [name, setName] = useState('')
const isRename = mode === 'rename'
const [name, setName] = useState(initial)
const inputRef = useRef<HTMLInputElement>(null)
const boxRef = useRef<HTMLDivElement>(null)
useEffect(() => { if (inputRef.current) inputRef.current.focus() }, [])
// A rename opens with the name in place; select the stem only, so typing
// replaces "notes" in "notes.md" and leaves the extension alone.
useEffect(() => {
const el = inputRef.current
if (!el) return
el.focus()
if (!isRename || !initial) return
const dot = initial.lastIndexOf('.')
el.setSelectionRange(0, dot > 0 ? dot : initial.length)
}, [])
useEffect(() => {
const h = (e: MouseEvent): void => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) onCancel() }
const k = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); onCancel() } }
@@ -661,18 +679,21 @@ export function NamePopup({ x, y, dir, kind = 'file', onConfirm, onCancel }: {
const top = Math.min(y + 6, window.innerHeight - 150)
const trimmed = name.trim()
const target = (dir ? dir + '/' : '') + trimmed
const noun = isFolder ? 'folder' : 'file'
const title = isRename ? `Rename ${noun}` : (isFolder ? 'New folder' : 'New file')
const ok = !!trimmed && !(isRename && trimmed === initial)
return (
<div className="pass-pop" ref={boxRef} style={{ left, top }}>
<div className="pass-head">{isFolder ? Icon.folder() : Icon.file()}<span>{isFolder ? 'New folder' : 'New file'}</span><span className="pass-esc">esc</span></div>
<div className="pass-head">{isFolder ? Icon.folder() : Icon.file()}<span>{title}</span><span className="pass-esc">esc</span></div>
<input ref={inputRef} className="pass-input" value={name} spellCheck={false}
placeholder={isFolder ? 'folder name…' : 'file name…'}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); if (trimmed) onConfirm(trimmed) }
if (e.key === 'Enter') { e.preventDefault(); if (ok) onConfirm(trimmed) }
else if (e.key === 'Escape') { e.preventDefault(); onCancel() }
}} />
<div className="pass-preview"><span className="pp-lbl">creates</span><code>{target ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> create {isFolder ? 'folder' : 'file'} · <kbd>esc</kbd> cancel</div>
<div className="pass-preview"><span className="pp-lbl">{isRename ? 'renames to' : 'creates'}</span><code>{trimmed ? target + (isFolder ? '/' : '') : '…'}</code></div>
<div className="pass-foot"><kbd></kbd> {isRename ? 'rename' : 'create'} {noun} · <kbd>esc</kbd> cancel</div>
</div>
)
}

View File

@@ -196,6 +196,8 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* Files in the root `tests/` folder stay a step greyer, so real source changes read first. Git list only. */
.git-row.test .git-name { color:var(--fg-3); }
.git-row.active .git-name { color:var(--fg-0); }
.git-name.del { text-decoration:line-through; color:var(--fg-3); }
.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
@@ -257,6 +259,12 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
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; }
/* Wrapped read-only view: the line folds inside its own column, so the number
column keeps its single number at the top of the block. The grid track must
drop max-content here — a folded line still measures its full unfolded width,
which would widen the row instead of breaking it. */
.editor.wrap { overflow-x:hidden; grid-template-columns:1fr; }
.editor.wrap .ln-code { flex:1 1 auto; white-space:pre-wrap; overflow-wrap:break-word; }
.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 .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
@@ -516,6 +524,22 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:11.5px; color:var(--fg-3
}
.ce-ta::selection { background:rgba(241,159,63,0.30); }
/* wrapped buffer — no separate gutter column: the layer must fold at the pane
width (max-content would never break), so the number rides along as a counter
on each line block and folded rows stay blank. The textarea folds the same
text at the same width in the same font, so both layers stay in step. */
.code-edit.wrap .ce-inner { width:100%; }
.code-edit.wrap .ce-pre, .code-edit.wrap .ce-ta {
white-space:pre-wrap; overflow-wrap:break-word; word-break:normal; padding-left:60px;
}
.code-edit.wrap .ce-pre { counter-reset:ce-ln; }
.ce-line { position:relative; min-height:20px; }
.ce-line::before {
counter-increment:ce-ln; content:counter(ce-ln);
position:absolute; left:-54px; top:0; width:34px; text-align:right;
font-size:12px; line-height:20px; color:var(--fg-3); user-select:none;
}
/* xterm.js host (real terminals) */
.term-xterm { flex:1; min-height:0; overflow:hidden; padding:6px 4px 6px 8px; background:var(--bg-1); }
.term-xterm .xterm { height:100%; }

View File

@@ -116,11 +116,24 @@ export function Terminal({ kind }: { kind: 'agent' | 'shell' }): React.ReactElem
setLive(false)
}
const ro = new ResizeObserver(() => { try { fit.fit() } catch { /* noop */ } })
// Fitting resizes xterm's own DOM, which the observer sees straight away.
// Done inline that is a write during observation delivery, and the browser
// reports "ResizeObserver loop completed with undelivered notifications" for
// every frame of a window drag. Deferring to the next frame keeps the write
// out of the delivery, and coalesces a drag into one fit per frame.
let frame = 0
const ro = new ResizeObserver(() => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
try { fit.fit() } catch { /* host not measured yet */ }
})
})
ro.observe(host)
return () => {
disposed = true
if (frame) cancelAnimationFrame(frame)
ro.disconnect()
offData()
offExit()

View File

@@ -81,11 +81,13 @@ export interface ViewLine {
}
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
/** Effective project settings (mirrors src/main/config.ts). */
export interface HelderConfig {
ai: { command: string; autoLaunch: boolean }
editor: { autoSave: boolean; tabSize: number }
editor: { autoSave: boolean; tabSize: number; wordWrap: WordWrap }
git: { confirmDiscard: boolean; confirmStage: boolean; confirmUnstage: boolean; defaultDiffMode: DiffMode; refreshInterval: number }
files: { exclude: string[]; followGitignore: boolean }
terminal: { shell: string | null }
@@ -94,7 +96,7 @@ export interface HelderConfig {
export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4 },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true },
terminal: { shell: null },

View File

@@ -94,3 +94,35 @@ describe('Stage + commit', () => {
await waitFor(() => expect(find(c, '.toast', 'Committed')).toBeTruthy())
})
})
describe('Explorer background menu', () => {
it('right-clicking the empty space offers New file + Show in Finder only', async () => {
const c = renderApp()
const body = await waitFor(() => {
const b = c.querySelector<HTMLElement>('.tree-body')
if (!b || !b.querySelector('.tree-row')) throw new Error('tree not ready')
return b
})
fireEvent.contextMenu(body)
const labels = await waitFor(() => {
const items = Array.from(c.querySelectorAll<HTMLElement>('.ctx-item')).map((el) => el.textContent)
if (!items.length) throw new Error('menu not open')
return items
})
expect(labels).toEqual(['New file', 'Show in Finder'])
// the root is never renameable or deletable — that would mean the project
expect(labels.some((l) => l?.includes('Delete'))).toBe(false)
})
it('a row right-click still shows the file menu, not the root one', async () => {
const c = renderApp()
const row = await waitFor(() => {
const r = find(c, '.tree-row', 'package.json')
if (!r) throw new Error('tree not ready')
return r
})
fireEvent.contextMenu(row)
await waitFor(() => expect(find(c, '.ctx-item', 'Rename file')).toBeTruthy())
expect(find(c, '.ctx-item', 'Copy file name')).toBeTruthy()
})
})

View File

@@ -55,3 +55,45 @@ describe('Editor view modes', () => {
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
})
})
// Default config wraps Markdown only (editor.wordWrap: 'markdown'). A wrapped
// buffer drops the fixed gutter column for one block per line, so the folded
// rows carry no number.
describe('Word wrap', () => {
async function openTree(c: HTMLElement, name: string): Promise<void> {
const row = await waitFor(() => {
const r = Array.from(c.querySelectorAll<HTMLElement>('.tree-row')).find((el) => el.textContent?.includes(name))
if (!r) throw new Error(`${name} not ready`)
return r
})
fireEvent.click(row)
}
it('folds long lines in Markdown, one numbered block per source line', async () => {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await openTree(c, 'README.md')
const edit = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.code-edit')
if (!el) throw new Error('editor not ready')
return el
})
expect(edit.classList.contains('wrap')).toBe(true)
expect(edit.querySelector('.ce-gutter')).toBeNull()
const ta = edit.querySelector<HTMLTextAreaElement>('.ce-ta')!
expect(ta.getAttribute('wrap')).toBe('soft')
expect(edit.querySelectorAll('.ce-line').length).toBe(ta.value.split('\n').length)
})
it('leaves other file types on the scrolling gutter layout', async () => {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await openTree(c, 'composer.json')
const edit = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.code-edit')
if (!el) throw new Error('editor not ready')
return el
})
expect(edit.classList.contains('wrap')).toBe(false)
expect(edit.querySelector('.ce-gutter')).toBeTruthy()
expect(edit.querySelector('.ce-line')).toBeNull()
})
})

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { simpleGit } from 'simple-git'
import { buildTreeFromPaths, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from '../src/main/fs-service'
import { buildTreeFromPaths, createProjectDir, createProjectFile, readAll, readDirChildren, readProjectFile, readTree, renameProjectEntry, writeProjectFile } from '../src/main/fs-service'
let dir = ''
afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
@@ -150,3 +150,48 @@ describe('createProjectFile', () => {
await expect(createProjectFile(dir, '../escape.txt')).rejects.toThrow()
})
})
describe('renameProjectEntry', () => {
it('renames a file in place and returns the new relative path', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await createProjectFile(dir, 'src/old.ts')
await writeProjectFile(dir, 'src/old.ts', 'x\n')
const next = await renameProjectEntry(dir, 'src/old.ts', 'new.ts')
expect(next).toBe('src/new.ts')
expect(await readProjectFile(dir, 'src/new.ts')).toBe('x\n')
})
it('renames a folder with everything inside it', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await createProjectFile(dir, 'old/a.ts')
await writeProjectFile(dir, 'old/a.ts', 'a\n')
const next = await renameProjectEntry(dir, 'old', 'new')
expect(next).toBe('new')
expect(await readProjectFile(dir, 'new/a.ts')).toBe('a\n')
})
it('changes only the case of a name', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await writeProjectFile(dir, 'notes.md', 'hi\n')
expect(await renameProjectEntry(dir, 'notes.md', 'Notes.md')).toBe('Notes.md')
expect(await readProjectFile(dir, 'Notes.md')).toBe('hi\n')
})
it('refuses a name that is already taken', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await writeProjectFile(dir, 'a.txt', 'a\n')
await writeProjectFile(dir, 'b.txt', 'b\n')
await expect(renameProjectEntry(dir, 'a.txt', 'b.txt')).rejects.toThrow()
expect(await readProjectFile(dir, 'b.txt')).toBe('b\n')
})
it('refuses a path-shaped or empty name', async () => {
dir = await mkdtemp(join(tmpdir(), 'helder-fs-'))
await createProjectDir(dir, 'sub')
await writeProjectFile(dir, 'a.txt', 'a\n')
await expect(renameProjectEntry(dir, 'a.txt', 'sub/a.txt')).rejects.toThrow()
await expect(renameProjectEntry(dir, 'a.txt', '../a.txt')).rejects.toThrow()
await expect(renameProjectEntry(dir, 'a.txt', ' ')).rejects.toThrow()
expect(await readProjectFile(dir, 'a.txt')).toBe('a\n')
})
})

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = () => {}
})
afterEach(() => { cleanup(); localStorage.clear() })
function find(c: HTMLElement, sel: string, text: string): HTMLElement | undefined {
return Array.from(c.querySelectorAll<HTMLElement>(sel)).find((el) => el.textContent?.includes(text))
}
async function open(c: HTMLElement, name: string): Promise<void> {
const row = await waitFor(() => {
const r = find(c, '.tree-row', name)
if (!r) throw new Error(`${name} not ready`)
return r
})
fireEvent.click(row)
}
function key(k: string, meta = false): void {
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: k, metaKey: meta, bubbles: true })) })
}
describe('Recent-files navigator (⌘↓)', () => {
it('Enter opens the highlighted file', async () => {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await open(c, 'composer.json')
await open(c, 'README.md')
await waitFor(() => expect(find(c, '.tb-crumb', 'README.md')).toBeTruthy())
key('ArrowDown', true)
const modal = await waitFor(() => {
const m = c.querySelector<HTMLElement>('.history-modal')
if (!m) throw new Error('history not open')
return m
})
// ⌘↓ pre-selects the previous file (index 1), i.e. composer.json.
expect(modal.querySelector('.hist-row.sel')?.textContent).toContain('composer.json')
key('Enter')
await waitFor(() => expect(c.querySelector('.history-modal')).toBeNull())
await waitFor(() => expect(find(c, '.tb-crumb', 'composer.json')).toBeTruthy())
})
})