From 3a941480a9bcf3ac37522f3bf8979545274c9dae Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Mon, 17 Aug 2026 10:43:38 +0200 Subject: [PATCH] commit --- CLAUDE.md | 1 + DESIGN.md | 1 + src/main/config.ts | 6 ++- src/main/diagnostics.ts | 10 ++++ src/main/fs-service.ts | 25 +++++++++- src/main/index.ts | 3 +- src/preload/index.ts | 1 + src/renderer/src/App.tsx | 86 ++++++++++++++++++++++++++++----- src/renderer/src/components.tsx | 8 ++- src/renderer/src/editor.tsx | 64 +++++++++++++++++------- src/renderer/src/env.d.ts | 1 + src/renderer/src/log.ts | 16 ++++++ src/renderer/src/overlays.tsx | 35 +++++++++++--- src/renderer/src/styles.css | 22 +++++++++ src/renderer/src/terminals.tsx | 15 +++++- src/renderer/src/types.ts | 6 ++- test/app-interactions.test.tsx | 32 ++++++++++++ test/editor-modes.test.tsx | 42 ++++++++++++++++ test/fs.test.ts | 47 +++++++++++++++++- test/history-open.test.tsx | 55 +++++++++++++++++++++ 20 files changed, 430 insertions(+), 46 deletions(-) create mode 100644 test/history-open.test.tsx 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}
)} +
-
+ )}