Compare commits
2 Commits
e126182ae6
...
1b212e2fb6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b212e2fb6 | |||
| 3a941480a9 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,6 +1008,7 @@ export function App(): React.ReactElement {
|
||||
|
||||
{/* workbench */}
|
||||
<div className="workbench">
|
||||
{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}
|
||||
@@ -960,6 +1018,7 @@ export function App(): React.ReactElement {
|
||||
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)} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={'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 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>
|
||||
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
55
test/history-open.test.tsx
Normal file
55
test/history-open.test.tsx
Normal 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())
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user