diff --git a/CLAUDE.md b/CLAUDE.md index b088c15..fa2143d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `
` 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.
diff --git a/DESIGN.md b/DESIGN.md
index 979fe0e..70ede56 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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
diff --git a/src/main/config.ts b/src/main/config.ts
index a6626a6..0f0afab 100644
--- a/src/main/config.ts
+++ b/src/main/config.ts
@@ -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 },
diff --git a/src/main/diagnostics.ts b/src/main/diagnostics.ts
index 54ec811..5544cd9 100644
--- a/src/main/diagnostics.ts
+++ b/src/main/diagnostics.ts
@@ -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 })
})
})
diff --git a/src/main/fs-service.ts b/src/main/fs-service.ts
index f382a56..ef72411 100644
--- a/src/main/fs-service.ts
+++ b/src/main/fs-service.ts
@@ -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
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 {
+ 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 {
const target = join(root, rel)
diff --git a/src/main/index.ts b/src/main/index.ts
index 5aab7eb..80bb18c 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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'
@@ -284,6 +284,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: /.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) })
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 0ebc848..45aac48 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -30,6 +30,7 @@ const api = {
delete: (path: string): Promise => ipcRenderer.invoke('fs:delete', path),
create: (path: string): Promise => ipcRenderer.invoke('fs:create', path),
mkdir: (path: string): Promise => ipcRenderer.invoke('fs:mkdir', path),
+ rename: (path: string, name: string): Promise => ipcRenderer.invoke('fs:rename', path, name),
},
shell: {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 065ba40..6684774 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -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 {
+ 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 = (m: Record): Record =>
+ 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 ⌘N
+
@@ -951,15 +1008,17 @@ export function App(): React.ReactElement {
{/* workbench */}
- { setActivePanel('git'); syncGitSel(e.target) }}>
- 300} />
-
- { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
+ {showGit && (<>
+ { setActivePanel('git'); syncGitSel(e.target) }}>
+ 300} />
+
+ { setAutoResize(false); setGitW((w) => clamp(w + dx, 160, 460)) }} />
+ >)}
{ setActivePanel('tree'); syncTreeSel(e.target) }}>
@@ -983,7 +1042,7 @@ export function App(): React.ReactElement {
{ 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 && { createFolder(newFolderPopup.dir, name); setNewFolderPopup(null) }}
onCancel={() => setNewFolderPopup(null)} />}
+ {renamePopup && { renameEntry(renamePopup.path, renamePopup.isDir, name); setRenamePopup(null) }}
+ onCancel={() => setRenamePopup(null)} />}
{overlay === 'search' && openFile(p, { line: n })} onClose={() => setOverlay(null)} changeSet={changeSet} activePath={active} activeText={bufferText(active)} showHidden={showHidden} />}
{overlay === 'history' && setOverlay(null)} changeSet={changeSet} />}
{overlay === 'projects' && actions.openProjectPath(p)} onClose={() => setOverlay(null)} />}
diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx
index f6f1576..de72956 100644
--- a/src/renderer/src/components.tsx
+++ b/src/renderer/src/components.tsx
@@ -26,6 +26,7 @@ export const Icon: Record React.ReactElement> = {
note: (p) => (),
help: (p) => (),
trash: (p) => (),
+ pencil: (p) => (),
finder: (p) => (),
eye: (p) => (),
push: (p) => (),
@@ -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
@@ -254,7 +256,9 @@ export function FileTree({ tree, openDirs, toggleDir, onOpen, onContext, activeP
}): React.ReactElement {
return (
-
+ {/* Right-clicking the empty space under the rows targets the project root.
+ Row menus stop propagation, so this only fires on the bare background. */}
+ onContext(e, { path: '', kind: 'root' })}>
diff --git a/src/renderer/src/editor.tsx b/src/renderer/src/editor.tsx
index 89653c5..2f533bd 100644
--- a/src/renderer/src/editor.tsx
+++ b/src/renderer/src/editor.tsx
@@ -19,18 +19,34 @@ function climbToLine(node: Node | null): HTMLElement | null {
}
/* Editable buffer: a transparent textarea over a Prism-highlighted , 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(null)
const gutterRef = useRef(null)
+ const preRef = useRef(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) => `${HL.hlLine(l, lang)}`).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 (
-
-
-
- {Array.from({ length: count }, (_, i) => {i + 1})}
+
+ {!wrap && (
+
+
+ {Array.from({ length: count }, (_, i) => {i + 1})}
+
-
+ )}
@@ -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 (
-
+
{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 ? (
No original versionThis file is new in the change.
) : editable ? (
-
+
) : (
built &&
)}
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts
index 4c3f7e9..a0e5470 100644
--- a/src/renderer/src/env.d.ts
+++ b/src/renderer/src/env.d.ts
@@ -28,6 +28,7 @@ interface HelderBridge {
delete: (path: string) => Promise
create: (path: string) => Promise
mkdir: (path: string) => Promise
+ rename: (path: string, name: string) => Promise
}
shell: {
reveal: (path: string) => void
diff --git a/src/renderer/src/log.ts b/src/renderer/src/log.ts
index 726b012..d9c0b99 100644
--- a/src/renderer/src/log.ts
+++ b/src/renderer/src/log.ts
@@ -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)
diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx
index 1d2a07a..002b828 100644
--- a/src/renderer/src/overlays.tsx
+++ b/src/renderer/src/overlays.tsx
@@ -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(null)
const boxRef = useRef(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 (
- {isFolder ? Icon.folder() : Icon.file()}{isFolder ? 'New folder' : 'New file'}esc
+ {isFolder ? Icon.folder() : Icon.file()}{title}esc
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() }
}} />
- creates{target ? target + (isFolder ? '/' : '') : '…'}
- ↵ create {isFolder ? 'folder' : 'file'} · esc cancel
+ {isRename ? 'renames to' : 'creates'}{trimmed ? target + (isFolder ? '/' : '') : '…'}
+ ↵ {isRename ? 'rename' : 'create'} {noun} · esc cancel
)
}
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index c26ad27..6a29f76 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -257,6 +257,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 +522,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%; }
diff --git a/src/renderer/src/terminals.tsx b/src/renderer/src/terminals.tsx
index 566889f..522f129 100644
--- a/src/renderer/src/terminals.tsx
+++ b/src/renderer/src/terminals.tsx
@@ -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()
diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts
index 2241c18..ff4e302 100644
--- a/src/renderer/src/types.ts
+++ b/src/renderer/src/types.ts
@@ -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 },
diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx
index cd3e5af..1da0aa6 100644
--- a/test/app-interactions.test.tsx
+++ b/test/app-interactions.test.tsx
@@ -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('.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('.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()
+ })
+})
diff --git a/test/editor-modes.test.tsx b/test/editor-modes.test.tsx
index c680349..f827d0d 100644
--- a/test/editor-modes.test.tsx
+++ b/test/editor-modes.test.tsx
@@ -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 {
+ const row = await waitFor(() => {
+ const r = Array.from(c.querySelectorAll('.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( ).container
+ await openTree(c, 'README.md')
+ const edit = await waitFor(() => {
+ const el = c.querySelector('.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('.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( ).container
+ await openTree(c, 'composer.json')
+ const edit = await waitFor(() => {
+ const el = c.querySelector('.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()
+ })
+})
diff --git a/test/fs.test.ts b/test/fs.test.ts
index f0c6fd8..7cf7934 100644
--- a/test/fs.test.ts
+++ b/test/fs.test.ts
@@ -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')
+ })
+})
diff --git a/test/history-open.test.tsx b/test/history-open.test.tsx
new file mode 100644
index 0000000..c71bfbb
--- /dev/null
+++ b/test/history-open.test.tsx
@@ -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(sel)).find((el) => el.textContent?.includes(text))
+}
+async function open(c: HTMLElement, name: string): Promise {
+ 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( ).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('.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())
+ })
+})