fixing lines overlapping
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-09-10 09:57:06 +02:00
parent 008adbfdb6
commit 1ebb9a0e74
18 changed files with 704 additions and 205 deletions

View File

@@ -6,14 +6,13 @@
"editor": {
"autoSave": false,
"tabSize": 4,
"wordWrap": "markdown",
"copyOnSelect": true
"wordWrap": "markdown"
},
"git": {
"confirmDiscard": true,
"confirmStage": false,
"confirmUnstage": false,
"defaultDiffMode": "diff",
"defaultDiffMode": "updated",
"refreshInterval": 10000
},
"files": {

View File

@@ -4,11 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project state
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, four diff modes + Split, search, and the *simulated* terminals are all working. IBM Plex Mono + IBM Plex Sans are bundled locally via `@fontsource/ibm-plex-mono` and `@fontsource/ibm-plex-sans`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 1 is scaffolded.** An `electron-vite` + React 18 + TypeScript app now lives at the repo root (`src/main`, `src/preload`, `src/renderer`). The prototype has been ported faithfully and renders against the **mock data** — full UI, git panel, the view modes, search, and the *simulated* terminals are all working. IBM Plex Mono + IBM Plex Sans are bundled locally via `@fontsource/ibm-plex-mono` and `@fontsource/ibm-plex-sans`; Prism is wired with the correct `markup-templating``php` load order; the renderer↔main clipboard bridge is in place (`src/preload/index.ts`).
**Phase 2 (real integrations) — complete; DESIGN.md fully implemented.** The app is feature-complete against the functional spec (the only intentional exception is the separate Go-to-File overlay — `⌘P` aliases the unified search instead, per the resolved decision). Editor is writable (save · autosave · dirty tabs · discard); session restore brings back open tabs/active/view modes; close-dirty prompts Save/Don't-Save/Cancel. There's a vitest suite (`npm test`, 51 tests) + ESLint + electron-builder packaging. All over IPC through the preload bridge (`src/preload/index.ts`):
- **Filesystem** — tree + in-memory content index, `chokidar` watch (`src/main/fs-service.ts`). The tree/index/search share one source of truth: `rg --files` (honors gitignore + `files.exclude`, includes dotfiles), with a recursive-walk fallback when ripgrep is unavailable.
- **Git** — `simple-git`: status→A/M/D/R, the four diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
- **Git** — `simple-git`: status→A/M/D/R, the diff views from HEAD-vs-worktree pairs, stage/unstage/commit/discard (`src/main/git-service.ts`).
- **Terminals** — real PTYs via `node-pty` (`src/main/pty-service.ts`) rendered with `@xterm/xterm` (`src/renderer/src/terminals.tsx`). Agent pane is a shell that auto-launches `claude`; bottom pane is a plain shell. Pass-on-to-Agent writes bracketed paste (`\x1b[200~ … \x1b[201~`) to the agent PTY. node-pty is native — `npm run rebuild` (also a `postinstall`) rebuilds it for Electron; it's N-API so the binary is portable.
- **Config** — `.helder/` per project (`src/main/config.ts`): `config.default.json` regenerated on launch (full defaults / live docs), sparse `config.json` deep-merged over it, and `theme.css` (created once, never overwritten) injected over the built-in dark theme. The **code font + size are CSS vars** (`--code-font`/`--code-size`/`--term-size`) the editor + xterm read, overridable from `theme.css`. ai command/autoLaunch + shell flow from config into the PTYs; a `.helder` file watcher hot-reloads config/theme.
@@ -17,7 +17,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
The renderer consumes FS/git/config/search via the store in `src/renderer/src/project.tsx` (`useProject` / `useProjectActions`), which falls back to the mock + default config when `window.helder` is absent (browser preview). `src/main/index.ts` registers all IPC handlers + the debounced watchers.
- **Editing** — the `code`/`updated` modes are a writable buffer: a transparent textarea over a Prism-highlighted `<pre>` with a scroll-synced gutter (`CodeEditor` in `editor.tsx`). `⌘S` saves to disk (`fs:write`), `editor.autoSave` debounce-saves on change, tabs show the dirty dot, and the git-row context menu has **Discard changes** gated by `git.confirmDiscard`. Original/Diff/Split stay read-only review views.
- **Editing** — the `code`/`updated` modes are a writable buffer: a transparent textarea over a Prism-highlighted `<pre>` with a scroll-synced gutter (`CodeEditor` in `editor.tsx`). `⌘S` saves to disk (`fs:write`), `editor.autoSave` debounce-saves on change, tabs show the dirty dot, and the git-row context menu has **Discard changes** gated by `git.confirmDiscard`. Original stays a read-only review view; Actual marks the changed lines in place, and Diff is the full-screen side-by-side view.
README steps 17 plus config + editing are all implemented for real. The editable overlay keeps the caret in view (the textarea is overflow-hidden under the scroller, so `CodeEditor` scrolls the container on input/keyup/click).
@@ -47,9 +47,9 @@ 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.
- **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. The full-screen Diff never wraps on purpose: its two panes align row by row. The hover original panel must fold at the same points as the editor, or the old line does not stay level with the current line.
- **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 three view modes (Actual / Original / Diff), plus Preview for markdown, all derive from one original-text + updated-text pair per changed file.** The prototype computes the pair with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output. **Actual is the writable buffer, and it marks the changed lines in place.** A marked row takes the teal `--add` tint, a 2px `--add` left rule, and a teal line number. There is no `+` glyph, no sign column, and no second row. The removed lines appear on hover: `mouseenter` on a marked line opens a 496px panel with a 2px `--del` left border over the agent + terminal column, and `mouseleave` closes it. The panel holds the whole original file and scrolls so the previous version of the hovered line sits level with the hovered line. There is no click, no pin, and no animation. A pure deletion has no current line to mark, so the neighbouring current line takes a 2px `--del` rule on its edge and opens the same panel. **Diff is the full-screen side-by-side view**: it covers the whole application, original left, updated right, lines aligned, and `Esc` returns to the previous mode. The old separate **Split** button is gone, and the `Diff` segment opens that view instead. `git.defaultDiffMode` defaults to `'updated'` (Actual), so a click on a changed git row opens the file in Actual. The git context menu's **Open diff** is the explicit way into the full-screen view. The colour language is token-based everywhere: **teal `--add` is what the file holds now, amber-deep `--del` is what it held before** — never green, never red. Syntax highlighting stays on in all modes. The design source for the marked lines and the hover panel is `design_handoff_helder_inline_diff/` (README.md + `03b-in-pane-diff.html`).
- **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.
- **Chrome budget:** title bar + tab strip + panel headers + status bar combined should stay ≈10% of vertical height. Keep it minimal.
- **`console.*` is not a log — use the logger.** Helder runs one process per project window, and every window past the first is spawned by `spawnInstance()` with `stdio: 'ignore'`; launched from Finder there's no terminal either. Console output is therefore discarded in real use. Log through `src/main/logger.ts` (main) or `src/renderer/src/log.ts``rlog` (renderer, forwarded over IPC to the same file). Never add a bare `catch {}` on an IPC/FS/git path: log the cause, then handle it.
@@ -104,7 +104,7 @@ The older `design_handoff_helder_workbench/` prototype is superseded by `docs/de
1. Electron shell + frameless dark window; port tokens to CSS vars; bundle IBM Plex.
2. Static layout: four resizable columns + title/status bars.
3. Real file tree + open files into tabs (read-only) with Prism highlighting.
4. Git panel from `git status` (read-only) → staging + commit → the four diff modes + Split.
4. Git panel from `git status` (read-only) → staging + commit → the three view modes, the hover original panel, and the full-screen Diff.
5. Search (ripgrep + fuzzy).
6. Terminals via node-pty + xterm.js; run `claude` in the agent pane.
7. Copy reference + Pass-on-to-Agent (clipboard + bracketed-paste into the agent PTY).

View File

@@ -64,19 +64,20 @@ Two stacked sections, each with its own header and count:
Each row, identical in both sections:
- A status letter on the far left: `A` added, `M` modified, `D` deleted, each in its own color (added green, modified amber, deleted red).
- A status letter on the far left: `A` added, `M` modified, `D` deleted, each in its own color (added teal, modified amber, deleted amber-deep).
- A file-type icon, then the file name.
- The dimmed relative path, aligned to the right of the name.
- Change counts at the far right: additions in green (`+N`) and deletions in red (`-N`).
- Change counts at the far right: additions in teal (`+N`) and deletions in amber-deep (`-N`).
- Deleted files are shown with the file name struck through and dimmed.
- Rows have default, hover, and selected states. The selected row is the file currently open in the editor.
### Interactions
- **Left-click** a row opens that file in the editor.
- **Left-click** a row opens that file in the editor, in **Actual**. It does not open the full-screen Diff.
- **Right-click** a row opens a context menu, designed to grow over time:
- In CHANGES (unstaged): **Stage file**, and **Discard changes**.
- In STAGED: **Unstage file**.
- In CHANGES (unstaged): **Open diff**, **Stage file**, and **Discard changes**.
- In STAGED: **Open diff**, and **Unstage file**.
- **Open diff** is the explicit way into the full-screen Diff (section 5).
- **Hover quick action**: on hover, an unstaged row shows a `+` to stage it in one click, and a staged row shows a `-` to unstage it.
- Optional section actions: **Stage all** on the CHANGES header and **Unstage all** on the STAGED header.
- **Discard** is a destructive action. It asks for confirmation first (controlled by `git.confirmDiscard`, default on). Discarding reverts a modified file to its committed state and restores a deleted file.
@@ -124,24 +125,26 @@ The third and widest column. Tabs on top, a view toolbar under them, and the cod
A row under the tabs:
- On the left, a status summary for the active file, for example `Modified +6 -2`.
- On the right, a four-segment control: **Original | Updated | Diff | Split**. The active segment is highlighted. This control appears only for files that have changes relative to their committed state. Its starting mode follows `git.defaultDiffMode` (default `Diff`).
- On the right, a segmented control: **Actual | Original | Diff**, with a fourth segment, **Preview**, on markdown files only. The active segment is highlighted. This control appears only for files that have changes relative to their committed state. Its starting mode follows `git.defaultDiffMode` (default `Actual`).
### View modes
All four are presentations of the same change set for the file:
All are presentations of the same change set for the file:
1. **Original**: the file as it was before the change, read-only. Changed and removed lines get a red bar in the left gutter. No inline plus or minus markers.
2. **Updated**: the current, editable version of the file. Added and changed lines get a green bar in the left gutter.
3. **Diff**: a single pane, unified inline diff. Removed lines are red with a `-`, added lines are green with a `+`, shown in sequence.
4. **Split**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. Removals are marked red on the left, additions green on the right. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
1. **Actual**: the current, editable version of the file. It marks the changed lines in place, with a teal tint, a 2px teal left rule, and a teal line number. There is no sign column, no `+` or `-` pair, and no removed line inline. A pure deletion has no current line to mark, so the neighbouring current line carries a 2px amber-deep rule on its top edge, or on its bottom edge at the end of the file.
2. **Original**: the file as it was before the change, read-only. Changed and removed lines get an amber-deep bar in the left gutter. No inline plus or minus markers.
3. **Diff**: the editor expands to full screen, covering the other columns. The original file is on the left and the updated file is on the right, lines aligned. `Esc`, or a collapse control in the corner, returns to the normal layout and the previously active mode.
4. **Preview**: markdown files only. It shows the rendered document instead of the source.
Shared rules: red always means removed or changed-from, green always means added or changed-to. Syntax highlighting stays on in all four modes. Line numbers follow `editor.lineNumbers` (default absolute).
**In Actual, the removed lines appear on hover.** The pointer enters a marked line, and a panel opens over the agent and terminal column: 496px wide, with a 2px amber-deep left border. The panel holds the whole original file, and it scrolls so that the previous version of the hovered line sits level with the hovered line. The pointer leaves the line, and the panel closes. There is no click, no pin, and no animation.
Shared rules: teal is what the file holds now, amber-deep is what it held before. There is no green and no red anywhere. Syntax highlighting stays on in all modes. Line numbers follow `editor.lineNumbers` (default absolute).
### Editing behavior
- 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.
- **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. The hover panel folds at the same points as the editor, so each old line stays level with its current line. Diff never wraps, because its two panes align row by row.
### Right-click in code
@@ -152,8 +155,8 @@ Right-clicking inside the code area, with or without a selection, shows a contex
### File-state behavior in the view modes
- **Added file**: only the Updated content exists. Original is empty, Diff shows everything as added (green), Split shows an empty left and the file on the right.
- **Deleted file**: only the Original content exists. Updated is empty, Diff shows everything as removed (red), Split shows the file on the left and an empty right.
- **Added file**: only the Actual content exists. Actual marks every line as added, Original is empty, and Diff shows an empty left pane with the file on the right.
- **Deleted file**: only the Original content exists. Actual is empty and has no line to mark, and Diff shows the file on the left with an empty right pane.
- **Binary or unsupported file**: cannot be shown as editable text. The editor shows a short placeholder stating the file cannot be displayed, and the view-mode control is not shown.
---
@@ -186,7 +189,7 @@ The bottom pane of the fourth column.
A bar across the bottom of the window. All items are **display only** for now; none are clickable.
- On the left: the current branch and the working-tree line totals, for example `feat/payments-balance +50 -38`.
- On the right: cursor position (`Ln, Col`), indentation (`Spaces: 4`), encoding (`UTF-8`), language (`PHP`), and the active view mode (`Diff`).
- On the right: cursor position (`Ln, Col`), indentation (`Spaces: 4`), encoding (`UTF-8`), language (`PHP`), and the active view mode (`Actual`).
---

View File

@@ -11,6 +11,7 @@ See what the agent changes, while it changes it — then hand it back exactly th
[![React](https://img.shields.io/badge/React-18-18202B?style=flat-square)](https://react.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-18202B?style=flat-square)](https://typescriptlang.org)
[![Tests](https://img.shields.io/badge/tests-155%20passing-8FBFB4?style=flat-square)](#development)
[![Vibe coded](https://img.shields.io/badge/vibe%20coded-100%25-E8913A?style=flat-square)](#fully-vibe-coded)
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.16.png" alt="The Helder workspace: source control, explorer, editor and the Claude Code agent in four columns" width="100%">
@@ -50,7 +51,7 @@ Four resizable columns, between a thin title bar and a status bar.
|---|---|
| **A · Source Control** | Commit box, staged list, changes list, per-file `+/` counts |
| **B · Explorer** | File tree with type icons and inline change badges |
| **C · Editor** | Tabs, syntax highlighting, and four views of every changed file |
| **C · Editor** | Tabs, syntax highlighting, and three views of every changed file |
| **D · Agent + Shell** | A live Claude Code terminal over a normal shell |
Every boundary is a splitter. Positions survive a restart, together with your open tabs and view modes.
@@ -67,15 +68,19 @@ Stack five of them, add your sentence, then press Enter once. Under the hood Hel
Right-click gives you the same reference on the clipboard. The Explorer offers it at file level.
### Four views of one change
### Three views of one change
Every changed file derives four views from one original-and-updated pair. `⌘M` cycles them.
Every changed file derives three views from one original-and-updated pair. `⌘M` cycles them. A markdown file adds **Preview**.
**Actual** · **Original** · **Diff** · **Split**
**Actual** · **Original** · **Diff**
**Actual** is the live buffer. It shows the file as it is now, with only the changed lines marked. Hover a marked line, and the whole original file appears over the agent column. It holds the matching scroll position, so the old line sits level with the new one.
**Diff** goes full screen over the whole app. The original is on the left, the updated file is on the right, and the lines align. `Esc` returns to the view you came from.
Added is teal, removed is amber-deep. There is no green and no red anywhere: the diff reads on shape and on a 2px rule, not on alarm.
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.30.png" alt="Split view: original on the left, updated on the right, with the changed line marked" width="100%">
<img src="docs/design/screenshots/Screenshot%202026-09-03%20at%2009.34.30.png" alt="Diff view: original on the left, updated on the right, with the changed line marked" width="100%">
### Git, in the columns you already read
@@ -110,7 +115,7 @@ Press `?` in the title bar for the full list.
| `⌘F` | Search contents and names |
| `⌘→` | Pass the selection to the agent |
| `⌘N` · `⌘P` | Open the project note · pass the whole note |
| `⌘M` | Cycle Actual · Original · Diff · Split |
| `⌘M` | Cycle Actual · Original · Diff |
| `⌘C` · `⌘↵` | Focus the commit message · commit the staged files |
| `⌘P` | Push the current branch |
| `⌘S` | Save this file |

View File

@@ -10,6 +10,7 @@ import { join } from 'node:path'
* and FONT SIZE live here (as CSS vars), not in the JSON
* Effective value = config.json over config.default.json, merged key by key.
*/
/** View a changed file opens in. 'diff' is the full-screen side-by-side overlay. */
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
@@ -55,7 +56,7 @@ export interface HelderConfig {
export const DEFAULTS: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
files: { exclude: [], followGitignore: false },
terminal: {
shell: null,

View File

@@ -2,8 +2,8 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FileTree, GitPanel, Icon, Tip, groupByDir } from './components'
import type { ContextTarget } from './components'
import { Editor, SplitView } from './editor'
import type { Cursor, Mode, Selection } from './editor'
import { Editor, OriginalPeek, SplitView } from './editor'
import type { Cursor, Mode, PeekInfo, Selection } from './editor'
import { Terminal, lid } from './terminals'
import { ConfirmModal, ContextMenu, HelpModal, HistoryModal, NamePopup, NotesModal, PassPopup, ProjectsModal, SearchModal, SymbolPopup, Toasts } from './overlays'
import { ProjectLauncher } from './launcher'
@@ -96,8 +96,8 @@ export function App(): React.ReactElement {
const [active, setActive] = useState<string | null>(null)
const [tabMode, setTabMode] = useState<Record<string, Mode>>({})
// Which git row opened each tab. Only Diff and Split follow it: Original and
// Actual always show HEAD and the file on disk.
// Which git row opened each tab. Only Diff follows it: Original and Actual
// always show HEAD and the file on disk.
const [tabSide, setTabSide] = useState<Record<string, DiffSide>>({})
const [openDirs, setOpenDirs] = useState<Set<string>>(new Set())
const [cursor, setCursor] = useState<Cursor | null>(null)
@@ -114,7 +114,7 @@ export function App(): React.ReactElement {
const [histInitSel, setHistInitSel] = useState(0)
const [menu, setMenu] = useState<Menu | null>(null)
const [toasts, setToasts] = useState<Toast[]>([])
const [splitFor, setSplitFor] = useState<string | null>(null)
const [peek, setPeek] = useState<PeekInfo | null>(null)
const [commitMsg, setCommitMsg] = useState('')
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)
@@ -540,7 +540,6 @@ export function App(): React.ReactElement {
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)
@@ -556,6 +555,18 @@ export function App(): React.ReactElement {
}
const defaultMode: Mode = proj.config.git.defaultDiffMode
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
// Which half the open tab shows. Also lights up the matching git row.
const activeSide: DiffSide | null = (active && tabSide[active]) || null
/* Diff is a full-screen overlay, so Esc has to put the pane back where it was.
* Any entry point counts — the segment, a git row, ⌘M — so this just trails
* the last mode that was not Diff. */
const preDiff = useRef<Mode>('updated')
useEffect(() => { if (mode !== 'diff') preDiff.current = mode }, [mode])
const splitOpen = !!active && mode === 'diff' && !!proj.diffs[active] && !HL.isImage(active)
function closeSplit(): void {
if (active) setTabMode((m) => ({ ...m, [active]: preDiff.current }))
}
function stageGuarded(p: string): void {
if (proj.config.git.confirmStage && !window.confirm(`Stage ${p}?`)) return
actions.stage(p)
@@ -612,11 +623,11 @@ export function App(): React.ReactElement {
setHistory((h) => [path, ...h.filter((p) => p !== path)].slice(0, 100))
reloadFromDisk(path)
setActive(path)
// Git rows open the diff; explorer / recent-files open the updated view.
// Unchanged files only have the plain editable "code" view.
// A changed file lands in the Actual view. Only an explicit `diff` request
// opens the full-screen overlay. Unchanged files have the "code" view only.
const openMode: Mode = changed ? (opts.diff ? 'diff' : 'updated') : 'code'
setTabMode((m) => ({ ...m, [path]: openMode }))
// Remember which git row this came from, so Diff/Split show that half. An
// Remember which git row this came from, so Diff shows that half. An
// explorer click carries no side and falls back to the whole file.
setTabSide((m) => {
const n = { ...m }
@@ -631,7 +642,6 @@ export function App(): React.ReactElement {
// so scroll its container to centre the target line. Line height is 20px with
// a 6px top pad; retry across a few frames until the content has rendered (the
// file may still be loading for a freshly-opened unchanged file).
setSplitFor(null)
setTabMode((m) => ({ ...m, [path]: changed ? 'updated' : 'code' }))
setCursor({ path, line: opts.line, col: 1 })
setSelection(null)
@@ -759,9 +769,9 @@ export function App(): React.ReactElement {
}
return false
}
// ↵ inside Git/Explorer: open the selected file (git → diff), toggle a folder.
// ↵ inside Git/Explorer: open the selected file, toggle a folder.
function openPanelSelection(): boolean {
if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { diff: true, side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
if (activePanel === 'git' && gitSelRow) { openFile(gitSelRow.path, { side: gitSelRow.staged ? 'staged' : 'unstaged' }); return true }
if (activePanel === 'tree' && treeSelItem) {
if (treeSelItem.type === 'dir') toggleDir(treeSelItem.path)
else openFile(treeSelItem.path)
@@ -771,23 +781,21 @@ export function App(): React.ReactElement {
}
// ⌘M cycles the active file through whatever views it supports: a changed file
// runs Updated → Original → Diff → Split (+ Preview for markdown); an unchanged
// markdown file toggles Code ↔ Preview. Plain unchanged files have one view, so
// there's nothing to cycle.
// runs Actual → Original → Diff (+ Preview for markdown); an unchanged markdown
// file toggles Code ↔ Preview. Plain unchanged files have one view, so there's
// nothing to cycle.
function cycleMode(): void {
if (!active) return
reloadFromDisk(active)
const md = HL.langFor(active) === 'markdown'
const hasDiff = !!proj.diffs[active]
const order: (Mode | 'split')[] = hasDiff
? ['updated', 'original', 'diff', 'split', ...(md ? (['preview'] as const) : [])]
const order: Mode[] = hasDiff
? ['updated', 'original', 'diff', ...(md ? (['preview'] as const) : [])]
: md ? ['code', 'preview'] : ['code']
if (order.length < 2) return
const cur = splitFor === active ? 'split' : (tabMode[active] || (hasDiff ? defaultMode : 'code'))
const cur = tabMode[active] || (hasDiff ? defaultMode : 'code')
const idx = order.indexOf(cur)
const next = order[(idx < 0 ? 0 : idx + 1) % order.length]
if (next === 'split') setSplitFor(active)
else { setTabMode((m) => ({ ...m, [active]: next as Mode })); setSplitFor(null) }
setTabMode((m) => ({ ...m, [active]: order[(idx < 0 ? 0 : idx + 1) % order.length] }))
}
function focusCommit(): void {
document.querySelector<HTMLTextAreaElement>('.commit-input')?.focus()
@@ -892,7 +900,7 @@ export function App(): React.ReactElement {
if (openPanelSelection()) { e.preventDefault(); return }
}
if (e.key === 'Escape') {
if (splitFor) setSplitFor(null)
if (splitOpen) closeSplit()
// Closing the note saves it there and then, rather than leaving the text
// to wait for the next blur.
else if (overlay === 'notes') { setOverlay(null); saveNote() }
@@ -963,7 +971,7 @@ export function App(): React.ReactElement {
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [active, splitFor, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
}, [active, splitOpen, overlay, focusZone, history, tabMode, commitMsg, proj, selection, confirm, menu, activePanel, gitNav, treeNav, gitSel, treeSel])
// ⌘R (View → Refresh, main process sends `view:refresh`) reloads the three
// left columns: git status (A) + the file tree (B) via a full project reload,
@@ -1017,10 +1025,6 @@ export function App(): React.ReactElement {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [proj.config.git.refreshInterval])
const mode: Mode = (active && tabMode[active]) || (active && proj.diffs[active] ? defaultMode : 'code')
// Which half the open tab shows. Also lights up the matching git row.
const activeSide: DiffSide | null = (active && tabSide[active]) || null
const crumb = active ? active.split('/') : []
// Whole-project line counts, shown in the status bar next to the branch.
const totals = proj.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
@@ -1128,9 +1132,8 @@ export function App(): React.ReactElement {
<div className={'col editor-col' + (activePanel === 'editor' ? ' panel-active' : '') + flashClass} onMouseDownCapture={() => { setFocusZone('editor'); setActivePanel('editor') }}>
<Editor active={active} mode={mode} side={activeSide}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); setSplitFor(null); reloadFromDisk(active) } }}
onContext={openMenu}
onSplit={(p) => setSplitFor(p)} splitOpen={splitFor === active}
setMode={(m) => { if (active) { setTabMode((mm) => ({ ...mm, [active]: m })); reloadFromDisk(active) } }}
onContext={openMenu} onPeek={setPeek}
cursor={cursor} selection={selection} setCursor={setCursor} setSelection={setSelection}
flash={lineFlash && lineFlash.path === active ? lineFlash : null}
bufferText={bufferText(active)} onEdit={onEdit} onSymbol={openSymbol} />
@@ -1145,6 +1148,10 @@ export function App(): React.ReactElement {
{/* keyed by root so the PTYs respawn in the new cwd when the project switches */}
{proj.ready && <RightColumn key={proj.root ?? 'none'} width={rightW} active={activePanel === 'terminal'}
onFocus={() => { setFocusZone('terminal'); setActivePanel('terminal') }} />}
{/* The hover reveal of Diff mode. It belongs to the body row, so it
covers the agent column and stops at the status bar. */}
{peek && <OriginalPeek {...peek} />}
</div>
{/* status bar — display only: nothing here is clickable */}
@@ -1166,7 +1173,7 @@ export function App(): React.ReactElement {
</div>
{/* overlays */}
{splitFor && <SplitView path={splitFor} side={tabSide[splitFor] ?? null} onClose={() => setSplitFor(null)} onContext={openMenu} />}
{splitOpen && <SplitView path={active as string} side={activeSide} onClose={closeSplit} onContext={openMenu} />}
{passPopup && <PassPopup x={passPopup.x} y={passPopup.y} refStr={passPopup.ref} code={passPopup.code}
onConfirm={(payload) => {
window.dispatchEvent(new CustomEvent('agentPaste', { detail: payload }))

View File

@@ -119,7 +119,7 @@ function GitRow({ c, activePath, activeSide, ctxPath, kbdId, onOpen, onContext,
return (
<div className={'git-row' + (staged ? ' staged' : '') + (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 })}
onClick={() => onOpen(c.path, { side })}
onContextMenu={(e) => onContext(e, { path: c.path, kind: 'git', staged })}
title={c.path}>
<span className={'git-stat ' + c.status}>{c.status}</span>

View File

@@ -1,7 +1,7 @@
/* Line-based LCS diff — the single source for the four view modes.
* Original / Updated / Diff / Split all derive from one (original, updated)
* text pair per changed file, whether that pair comes from the mock or from
* real `git diff` (original = HEAD:path, updated = working tree). */
/* Line-based LCS diff — the single source for the view modes. Actual, Original
* and the side-by-side Diff all derive from one (original, updated) text pair
* per changed file, whether that pair comes from the mock or from real
* `git diff` (original = HEAD:path, updated = working tree). */
import type { Diff, DiffRow, GitStatus, SideLine, SplitRow } from './types'
interface Op { t: 'same' | 'del' | 'add'; a?: number; b?: number }

View File

@@ -1,5 +1,5 @@
/* Editor: four view modes (Original / Updated / Diff / Split) + line selection */
import React, { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
/* Editor: four view modes (Actual / Original / Diff / Preview) + line selection */
import React, { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { Diff, DiffSide, ViewLine } from './types'
import { rowId } from './types'
import { useProject } from './project'
@@ -57,6 +57,20 @@ function climbToLine(node: Node | null): HTMLElement | null {
return el || null
}
/** A line the file-level diff changed, as the editable buffer needs it. */
export interface ChangeMark {
line: number
/** The line itself is new or rewritten: tint the row, lift its number. */
tint: boolean
/** A removed run owns no line here, so its rule sits on an edge of this one. */
gap: 'above' | 'below' | null
}
/** A mark plus the geometry of its line, in `.ce-inner` coordinates. */
interface Band extends ChangeMark { top: number; height: number }
/** The line box of `.ce-pre` and its top padding — the unwrapped arithmetic. */
const LINE_H = 20
const PAD_TOP = 10
/* Editable buffer: a transparent textarea over a Prism-highlighted <pre>, with a
* scroll-synced line-number gutter. Live highlighting while typing.
*
@@ -68,42 +82,48 @@ function climbToLine(node: Node | null): HTMLElement | null {
* 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, flash, onChange, onContext, onSymbol }: {
function CodeEditor({ path, text, lang, wrap, flash, marks, onHover, onChange, onContext, onSymbol }: {
path: string
text: string
lang: string | null
wrap: boolean
/** Line to paint orange for a second after a jump. */
flash: FlashLine | null
/** Changed lines of this file, from the HEAD-vs-disk diff. */
marks: ChangeMark[]
/** The marked line under the pointer, with its top in the pane's viewport. */
onHover: (h: { line: number; top: number } | null) => void
onChange: (text: string) => void
onContext: OnContext
onSymbol: OnSymbol
}): React.ReactElement {
const scrollRef = useRef<HTMLDivElement>(null)
const innerRef = useRef<HTMLDivElement>(null)
const gutterRef = useRef<HTMLDivElement>(null)
const preRef = useRef<HTMLPreElement>(null)
const tabSize = useProject().config.editor.tabSize
const symVer = useSymbols() // re-highlight once the declared-name list lands
const metaDown = useMetaKey()
const tinted = useMemo(() => new Set(marks.filter((m) => m.tint).map((m) => m.line)), [marks])
const html = useMemo(
() => (wrap
? text.split('\n').map((l) => `<div class="ce-line">${HL.hlLine(l, lang)}</div>`).join('')
? text.split('\n').map((l, i) => `<div class="ce-line${tinted.has(i + 1) ? ' chg' : ''}">${HL.hlLine(l, lang)}</div>`).join('')
: HL.hlText(text, lang) + '\n'),
// symVer is not read here: HL marks the known class tokens from a module-level
// set, so this is what recomputes the HTML when that set lands.
// eslint-disable-next-line react-hooks/exhaustive-deps
[text, lang, wrap, symVer],
[text, lang, wrap, symVer, tinted],
)
/* One text block, not one element per line: the font is monospace and every
* row is exactly 20px, so the numbers land on the same grid a list of divs
* gave — for one node instead of thousands on a long file. Nothing styles a
* single number today; the day something must, this goes back to spans. */
/* One block of markup, not one element per line: the font is monospace and
* every row is exactly 20px, so the numbers land on the same grid a list of
* divs gave — for one node instead of thousands on a long file. Only a
* changed number needs an element of its own. */
const gutter = useMemo(() => {
const n = text.split('\n').length
let out = '1'
for (let i = 2; i <= n; i++) out += '\n' + i
return out
}, [text])
const out: string[] = []
for (let i = 1; i <= n; i++) out.push(tinted.has(i) ? `<span class="chg">${i}</span>` : String(i))
return out.join('\n')
}, [text, tinted])
// The band sits behind the text, so it needs the geometry of the line. Unwrapped
// that is arithmetic; wrapped, a line can be several rows tall, so measure it.
const [flashBox, setFlashBox] = useState<{ top: number; height: number } | null>(null)
@@ -113,9 +133,56 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
setFlashBox(row ? { top: row.offsetTop, height: row.offsetHeight } : { top: 6 + (flash.line - 1) * 20, height: 20 })
}, [flash, wrap, text])
// A folded line is as tall as the rows it spans, so a width change moves every
// band under it. Nothing else here depends on the pane width.
const [paneW, setPaneW] = useState(0)
useEffect(() => {
const el = scrollRef.current
if (!wrap || !el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setPaneW(el.clientWidth))
ro.observe(el)
return () => ro.disconnect()
}, [wrap])
/* The change bands. They go behind the transparent textarea, so each one needs
* the geometry of its line the way the flash band does: arithmetic while the
* buffer is one blob, measured once a line can fold. */
const [bands, setBands] = useState<Band[]>([])
useLayoutEffect(() => {
if (!marks.length) { setBands([]); return }
const kids = preRef.current?.children
setBands(marks.map((m) => {
const row = wrap ? (kids?.[m.line - 1] as HTMLElement | undefined) : undefined
return row
? { ...m, top: row.offsetTop, height: row.offsetHeight }
: { ...m, top: PAD_TOP + (m.line - 1) * LINE_H, height: LINE_H }
}))
}, [marks, wrap, text, paneW])
/* The hover reveal. The pointer lands on the textarea rather than on a row, so
* the line comes from the bands — the same geometry that drew the tint. */
const hoverRef = useRef<number | null>(null)
function report(list: Band[], line: number | null): void {
const b = line == null ? undefined : list.find((x) => x.line === line)
hoverRef.current = b ? b.line : null
onHover(b && scrollRef.current ? { line: b.line, top: b.top - scrollRef.current.scrollTop } : null)
}
function onMove(e: React.MouseEvent): void {
if (!bands.length && hoverRef.current == null) return
const box = innerRef.current?.getBoundingClientRect()
if (!box) return
const y = e.clientY - box.top
const hit = bands.find((b) => y >= b.top && y < b.top + b.height)
const line = hit ? hit.line : null
if (line !== hoverRef.current) report(bands, line)
}
// An edit moves the bands under a held pointer; the panel follows them.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => { if (hoverRef.current != null) report(bands, hoverRef.current) }, [bands])
function onScroll(): void {
const s = scrollRef.current
if (s && gutterRef.current) gutterRef.current.style.transform = `translateY(${-s.scrollTop}px)`
if (hoverRef.current != null) report(bands, hoverRef.current)
}
// 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).
@@ -209,11 +276,20 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
<div className={'code-edit' + (wrap ? ' wrap' : '') + (metaDown ? ' sym-live' : '')}>
{!wrap && (
<div className="ce-gutterwrap">
<div className="ce-gutter" ref={gutterRef}>{gutter}</div>
<div className="ce-gutter" ref={gutterRef} dangerouslySetInnerHTML={{ __html: gutter }} />
</div>
)}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}>
<div className="ce-inner">
{/* No marks, no hover: an unchanged file pays nothing for the reveal. */}
<div className="ce-scroll" ref={scrollRef} onScroll={onScroll} onClick={handleTokenClick}
onMouseMove={marks.length ? onMove : undefined}
onMouseLeave={marks.length ? () => { if (hoverRef.current != null) report(bands, null) } : undefined}>
<div className="ce-inner" ref={innerRef}>
{bands.map((b) => (
<Fragment key={b.line}>
{b.tint && <div className="ce-band" style={{ top: b.top, height: b.height }} />}
{b.gap && <div className="ce-gaprule" style={{ top: b.gap === 'above' ? b.top : b.top + b.height - 2 }} />}
</Fragment>
))}
{flash && flashBox && <div key={flash.id} className="ce-flash" style={{ top: flashBox.top, height: flashBox.height }} />}
<textarea className="ce-ta" value={text} spellCheck={false} autoComplete="off"
wrap={wrap ? 'soft' : 'off'} style={{ tabSize }}
@@ -229,6 +305,17 @@ function CodeEditor({ path, text, lang, wrap, flash, onChange, onContext, onSymb
)
}
/* The file has no HEAD version. Shown by Original mode and by the hover
* overlay, so both say the same thing. */
function NoOriginal(): React.ReactElement {
return (
<div className="empty-ed">
<div className="big" style={{ color: 'var(--add)' }}>No original version</div>
<div className="note">This file is new in the change.</div>
</div>
)
}
/* Rendered-markdown preview: read-only, derived from the live buffer text. */
function MarkdownView({ path, text, onContext }: { path: string; text: string; onContext: OnContext }): React.ReactElement {
const html = useMemo(() => renderMarkdown(text), [text])
@@ -371,6 +458,7 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
const cls = 'ln-row'
+ (l.row === 'add' ? ' add' : l.row === 'del' ? ' del' : '')
+ (l.row === 'bar-add' ? ' bar-add' : l.row === 'bar-del' ? ' bar-del' : '')
+ (l.gap === 'above' ? ' gap-above' : l.gap === 'below' ? ' gap-below' : '')
+ (no === curLine && !inSel && !l.row ? ' cursor' : '')
+ (inSel ? ' selrange' : '')
return (
@@ -385,13 +473,65 @@ function PaneView({ cacheKey, path, lines, lang, showSign, wrap, cursor, selecti
)
}
/* Build the line descriptors for a given mode. The caller picks which diff to
* pass: Original and Actual always get the file-level HEAD-vs-disk pair, while
* Diff gets the pair of the git row you clicked. */
/** The original lines behind one current line: the range to mark amber, and the
* one to line up with the hovered row. */
interface PeekTarget {
anchor: number
from: number | null
to: number | null
}
/* Actual: the current file with the changed lines marked in place. Removals own
* no line here, so a run of them puts its rule on the edge of the line that took
* its place — unless that line is itself an added one, which already says the
* same thing in teal. The map is what the hover overlay reads. */
export function buildDiffView(diff: Diff): { lines: ViewLine[]; peek: Map<number, PeekTarget> } {
const peek = new Map<number, PeekTarget>()
const above = new Set<number>()
let tail: PeekTarget | null = null
let dels: number[] = []
let adds: number[] = []
let prevSame = 0
const flush = (nextNew: number | null): void => {
if (dels.length && adds.length) {
adds.forEach((no, k) => {
const old = dels[Math.min(k, dels.length - 1)]
peek.set(no, { anchor: old, from: old, to: old })
})
} else if (dels.length) {
const target: PeekTarget = { anchor: dels[0], from: dels[0], to: dels[dels.length - 1] }
if (nextNew == null) tail = target
else { peek.set(nextNew, target); above.add(nextNew) }
} else {
adds.forEach((no) => peek.set(no, { anchor: Math.max(1, prevSame), from: null, to: null }))
}
dels = []; adds = []
}
for (const r of diff.rows) {
if (r.sign === '-') dels.push(r.oldNo as number)
else if (r.sign === '+') adds.push(r.newNo as number)
else { flush(r.newNo); prevSame = r.oldNo as number }
}
flush(null)
// A run removed at the end of the file has no line after it, so its rule goes
// under the last one instead.
const last = diff.right.length ? diff.right[diff.right.length - 1].no : null
if (tail && last != null) peek.set(last, tail)
const lines: ViewLine[] = diff.right.map((l) => ({
no: l.no,
text: l.text,
row: l.mark === 'add' ? 'add' : null,
gap: above.has(l.no) ? 'above' : tail && l.no === last ? 'below' : null,
}))
return { lines, peek }
}
/* Build the line descriptors for the read-only whole-file modes. Actual has its
* own builder above, because it also has to answer what each line used to be. */
function buildLines(mode: Mode, diff: Diff | null, fileText: string | undefined): { lines: ViewLine[]; showSign: boolean } {
if (mode === 'original' && diff) return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'del' ? 'bar-del' : null })), showSign: false }
if (mode === 'updated' && diff) return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === 'add' ? 'bar-add' : null })), showSign: false }
if (mode === 'diff' && diff) return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === '+' ? 'add' : r.sign === '-' ? 'del' : null })), showSign: true }
// plain file
const arr = (fileText || '').replace(/\n$/, '').split('\n')
return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false }
@@ -408,15 +548,15 @@ function segmentsFor(hasDiff: boolean, isMarkdown: boolean): { id: Mode; label:
return segs
}
export function Editor({ active, mode, side, setMode, onContext, onSplit, splitOpen, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
export function Editor({ active, mode, side, setMode, onContext, onPeek, cursor, selection, setCursor, setSelection, flash, bufferText, onEdit, onSymbol }: {
active: string | null
mode: Mode
/** Which git row opened this tab. Only Diff and Split follow it. */
/** Which git row opened this tab. Only Diff follows it. */
side: DiffSide | null
setMode: (m: Mode) => void
onContext: OnContext
onSplit: (path: string) => void
splitOpen: boolean
/** The original of the line under the pointer, for the overlay App renders. */
onPeek: (p: PeekInfo | null) => void
cursor: Cursor | null
selection: Selection | null
setCursor: (c: Cursor) => void
@@ -434,8 +574,8 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
const change = tab ? PROJECT.changes.find((c) => c.path === tab.path) : null
// Original / Actual always show the whole file: HEAD vs disk.
const diff = tab ? PROJECT.diffs[tab.path] : null
// Diff / Split show the half you clicked in the git panel. A file with only
// one row has an identical pair either way.
// Diff shows the half you clicked in the git panel. A file with only one row
// has an identical pair either way.
const rowDiff = (tab && side ? PROJECT.rowDiffs[rowId(tab.path, side === 'staged')] : null) || diff
const bothSides = !!tab && !!PROJECT.rowDiffs[rowId(tab.path, true)] && !!PROJECT.rowDiffs[rowId(tab.path, false)]
const lang = tab ? HL.langFor(tab.path) : null
@@ -443,7 +583,7 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
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
// a paragraph is one very long line. The Diff 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)
@@ -456,26 +596,59 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
else if (hasDiff) effMode = mode === 'code' || mode === 'preview' ? 'updated' : mode
else effMode = 'code'
// The diff actually on screen: the row pair for Diff, the whole file otherwise.
// Diff is the full-screen side-by-side view App lays over everything, so the
// pane behind it keeps showing the editable buffer.
const paneMode: Mode = effMode === 'diff' ? 'updated' : effMode
// The pair the bar counts: the clicked git row for Diff, the whole file otherwise.
const shown = effMode === 'diff' ? rowDiff : diff
/* Actual marks the changed lines and answers what each one replaced. Both come
* from the file-level pair, never from the git row: the buffer on screen is the
* file on disk. */
const diffView = useMemo(() => (paneMode === 'updated' && diff ? buildDiffView(diff) : null), [paneMode, diff])
const marks = useMemo<ChangeMark[]>(() => (diffView
? diffView.lines
.filter((l) => l.row === 'add' || l.gap)
.map((l) => ({ line: l.no as number, tint: l.row === 'add', gap: l.gap ?? null }))
: []), [diffView])
let built: { lines: ViewLine[]; showSign: boolean } | null = null
if (tab && effMode !== 'preview') {
if (hasDiff) built = buildLines(effMode, shown, PROJECT.files[tab.path])
else built = buildLines('code', null, PROJECT.files[tab.path])
if (tab && paneMode !== 'preview') {
built = hasDiff ? buildLines(paneMode, diff, PROJECT.files[tab.path]) : buildLines('code', null, PROJECT.files[tab.path])
}
// File-level status, taken from the whole-file diff rather than a single row:
// a file can be staged as modified and deleted on disk at the same time.
const fileStatus = diff ? (diff.deleted ? 'D' : diff.added ? 'A' : 'M') : change?.status
const statusWord = fileStatus === 'A' ? 'Added' : fileStatus === 'D' ? 'Deleted' : 'Modified'
const activeSeg = splitOpen ? 'split' : effMode
const activeSeg = effMode
// Keyed off the git status, not the line count: an empty file that still exists
// (a just-created one, or one emptied by hand) has zero lines too, and must get
// the editor rather than the "deleted" placeholder.
const emptyUpdated = effMode === 'updated' && fileStatus === 'D'
const emptyOriginal = effMode === 'original' && fileStatus === 'A'
// Editable in the live-buffer modes; Original/Diff/Preview stay read-only views.
const editable = effMode === 'code' || effMode === 'updated'
const emptyUpdated = paneMode === 'updated' && fileStatus === 'D'
const emptyOriginal = paneMode === 'original' && fileStatus === 'A'
// Editable in the live-buffer modes; Original/Preview stay read-only views.
const editable = paneMode === 'code' || paneMode === 'updated'
/* The hover reveal of Actual. CodeEditor owns the geometry, because over a
* textarea there is no row to read the pointer off; `top` is the hovered
* line's offset inside the pane viewport, and the overlay aligns on it. */
const [hover, setHover] = useState<{ line: number; top: number } | null>(null)
const onHoverLine = useCallback((h: { line: number; top: number } | null) => setHover(h), [])
useEffect(() => { setHover(null) }, [active, effMode, side])
const target = hover && diffView ? diffView.peek.get(hover.line) ?? null : null
const peekLines = useMemo<ViewLine[]>(() => (target && diff
? diff.left.map((l) => ({
no: l.no,
text: l.text,
row: target.from != null && l.no >= target.from && l.no <= (target.to as number) ? 'del' : null,
}))
: []), [diff, target])
useEffect(() => {
onPeek(active && target && hover
? { path: active, lines: peekLines, lang, wrap, anchor: target.anchor, top: hover.top, empty: peekLines.length === 0 }
: null)
}, [active, target, hover, peekLines, lang, wrap, onPeek])
useEffect(() => () => onPeek(null), [onPeek])
return (
<Fragment>
@@ -516,27 +689,22 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
{segments.map((s) => (
<button key={s.id} className={activeSeg === s.id ? 'on' : ''} onClick={() => setMode(s.id)}>{s.label}</button>
))}
{hasDiff && (
<button className={'split-btn' + (activeSeg === 'split' ? ' on' : '')} onClick={() => onSplit(tab.path)} title="Split — full screen side-by-side">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none"><rect x="1" y="1.5" width="10" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><line x1="6" y1="1.5" x2="6" y2="10.5" stroke="currentColor" strokeWidth="1.2" /></svg>
Split
</button>
)}
</div>
)}
</div>
{isImage ? (
<ImageView path={tab.path} onContext={onContext} />
) : effMode === 'preview' ? (
) : paneMode === 'preview' ? (
<MarkdownView path={tab.path} text={bufferText} onContext={onContext} />
) : emptyUpdated ? (
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--fg-3)' }}>This file was deleted in the change.</div></div>
<div className="empty-ed"><div className="big" style={{ color: 'var(--del)' }}>No updated version</div><div className="note">This file was deleted in the change.</div></div>
) : 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>
<NoOriginal />
) : editable ? (
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
<CodeEditor path={tab.path} text={bufferText} lang={lang} wrap={wrap} flash={flash} marks={marks}
onHover={onHoverLine} onChange={onEdit} onContext={onContext} onSymbol={onSymbol} />
) : (
built && <PaneView cacheKey={tab.path + ':' + effMode + ':' + (effMode === 'diff' ? side ?? '' : '')} path={tab.path} lines={built.lines}
built && <PaneView cacheKey={tab.path + ':' + paneMode} path={tab.path} lines={built.lines}
lang={lang} showSign={built.showSign} wrap={wrap} cursor={cursor} selection={selection}
setCursor={setCursor} setSelection={setSelection} onContext={onContext} onSymbol={onSymbol} />
)}
@@ -546,10 +714,58 @@ export function Editor({ active, mode, side, setMode, onContext, onSplit, splitO
)
}
/* Full-screen side-by-side split view */
/** Everything the hover overlay needs: the original of the open file, the lines
* the hovered one replaced, and where that hovered line sits on screen. */
export interface PeekInfo {
path: string
lines: ViewLine[]
lang: string | null
wrap: boolean
/** Original line to put level with the hovered one. */
anchor: number
/** The hovered row's top, measured inside the editor viewport. */
top: number
empty: boolean
}
const noop = (): void => undefined
/* The original file, over the agent column and level with the editor. It takes
* no pointer of its own, so the panel can never steal the hover from the row
* that opened it. */
export function OriginalPeek({ path, lines, lang, wrap, anchor, top, empty }: PeekInfo): React.ReactElement {
const bodyRef = useRef<HTMLDivElement>(null)
// Measured, not counted: a folded line is taller than one row, so only the
// real geometry keeps the two versions on one line of the screen.
useLayoutEffect(() => {
const scroller = bodyRef.current?.querySelector('.editor') as HTMLElement | null
const row = scroller?.querySelector(`.ln-row[data-line="${anchor}"]`) as HTMLElement | null
if (!scroller || !row) return
scroller.scrollTop += row.getBoundingClientRect().top - scroller.getBoundingClientRect().top - top
}, [anchor, top, lines])
return (
<div className="peek">
<div className="peek-head">
<span className="ph-label">Original</span>
<span className="ph-note">before this change · same scroll</span>
<span className="ph-hint">hold hover</span>
</div>
<div className="peek-body" ref={bodyRef}>
{empty ? <NoOriginal /> : (
<PaneView cacheKey={path + ':peek'} path={path} lines={lines} lang={lang} showSign={false} wrap={wrap}
cursor={null} selection={null} setCursor={noop} setSelection={noop} onContext={noop} onSymbol={noop} />
)}
</div>
</div>
)
}
/* The Diff view: the two versions side by side, over the whole window. Esc puts
* the pane back where it was. */
export function SplitView({ path, side, onClose, onContext }: {
path: string
/** Which git row opened this file. Split compares that row's pair. */
/** Which git row opened this file. Diff compares that row's pair. */
side: DiffSide | null
onClose: () => void
onContext: OnContext
@@ -608,7 +824,7 @@ export function SplitView({ path, side, onClose, onContext }: {
</div>
</div>
<div className="split-pane right">
<div className="split-label">Updated <span>after</span></div>
<div className="split-label">Actual <span>after</span></div>
<div className="editor" ref={rightRef} onScroll={() => sync(rightRef.current, leftRef.current)} onContextMenu={ctx}>
{diff.split.map((row, i) => (
<div key={i} data-line={row.r ? row.r.no : undefined} className={'ln-row' + (row.r && row.r.mark === 'add' ? ' add bar-add' : '') + (!row.r ? ' empty' : '')}>

View File

@@ -493,7 +493,7 @@ const SHORTCUTS: { group: string; rows: { keys: string[]; label: string }[] }[]
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
{ keys: ['⌘', 'G'], label: 'Source Control column on or off' },
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff · Split' },
{ keys: ['⌘', 'M'], label: 'Cycle Actual · Original · Diff' },
{ keys: ['⌘', 'P'], label: 'Push the current branch' },
],
},

View File

@@ -21,7 +21,7 @@ export interface ProjectData {
changes: Change[]
/** Per file: HEAD vs disk. Drives the Original and Actual views. */
diffs: Record<string, Diff>
/** Per row id: that row's own pair. Drives Diff and Split. */
/** Per row id: that row's own pair. Drives Diff. */
rowDiffs: Record<string, Diff>
/** Paths that have a staged row. */
staged: Set<string>

View File

@@ -169,7 +169,9 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.tb-toggle.muted, .tb-toggle.muted kbd { color:var(--fg-3); }
.tb-toggle.muted:hover { color:var(--fg-2); }
.workbench { flex:1; display:flex; min-height:0; overflow:hidden; }
/* position:relative so the Diff hover overlay can pin itself to the body row —
top, right and bottom — and cover the agent column and nothing else. */
.workbench { flex:1; display:flex; min-height:0; overflow:hidden; position:relative; }
/* --col-bg holds each column's resting background so the ⌘R flash below can
animate back to it, whichever state the column is in. */
@@ -357,14 +359,16 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.ln-code { flex:1 0 auto; white-space:pre; padding:0 16px 0 0; 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; }
column keeps its single number at the top of the block. Block flow, not grid:
an auto grid row takes its height from the row's *unwrapped* line box, so a
folded line kept a 20px row and painted over the lines under it. Nothing is
lost — with no sideways scroll the max-content track has no work to do. */
.editor.wrap { display:block; overflow-x:hidden; }
.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:18px; background:var(--bg-0); }
.empty-ed svg { display:none; }
.empty-ed .big { font-family:var(--ui); font-size:14px; line-height:1.7; color:var(--fg-3); }
.empty-ed .note { font-family:var(--mono); font-size:12px; color:var(--fg-3); }
.empty-ed .klist { display:flex; flex-direction:column; gap:6px; font-size:13px; }
.empty-ed .klist div { display:flex; gap:14px; align-items:center; justify-content:space-between; min-width:260px; padding:6px 0; color:var(--fg-1); }
@@ -374,11 +378,7 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
letter-spacing:.14em; text-transform:uppercase; color:var(--fg-3); }
.diff-bar .a{color:var(--add);font-family:var(--mono);font-weight:500;font-size:12px;}
.diff-bar .d{color:var(--del);font-family:var(--mono);font-weight:500;font-size:12px;}
.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
/* four-segment view control — the active side is the amber fill with ink text */
/* segmented view control — the active side is the amber fill with ink text */
.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:var(--r-sm); overflow:hidden; }
.diff-bar .seg button { background:transparent; border:0; border-left:1px solid var(--border-2); color:var(--fg-2);
font-family:var(--ui); font-size:11px; font-weight:400; line-height:1; padding:5px 12px; cursor:pointer; display:flex; align-items:center; gap:6px;
@@ -386,7 +386,6 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.diff-bar .seg button:first-child { border-left:0; }
.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
.diff-bar .seg button.on, .diff-bar .seg button.on:hover { background:var(--accent); color:#171C22; font-weight:500; }
.diff-bar .seg .split-btn svg { display:none; }
.diff-bar .db-lang { font-family:var(--mono); font-weight:700; font-size:10px; color:var(--fg-3); letter-spacing:.14em; text-transform:uppercase; }
/* Which pair the diff compares. Only shown when a file is staged AND edited
again, so the two git rows can be told apart. */
@@ -446,14 +445,14 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
font-size:10px; letter-spacing:.14em; text-transform:uppercase; white-space:nowrap; }
.md-body table.md-table tbody tr:last-child td { border-bottom:0; }
/* gutter change bars (Original / Updated / Split) */
/* gutter change bars (Original / Diff) */
.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
.ln-row.empty { background:transparent; }
/* full-screen split */
/* Diff — the two versions side by side, over the whole window */
.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; }
/* The split covers the whole window, title bar included, so its own header has
/* The view covers the whole window, title bar included, so its own header has
to reserve the space macOS keeps for the traffic lights — otherwise the file
badge and the path sit underneath them. */
.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:14px; padding:0 16px 0 82px;
@@ -478,6 +477,42 @@ kbd { flex:0 0 auto; font-family:var(--mono); font-size:12px; font-weight:600; c
.split-body .ln-row { min-height:22px; }
.split-body .ln-gutter { flex:0 0 52px; width:52px; }
/* ============ in-pane diff (Actual) ============ */
/* A changed line in the editable buffer. The tint has to sit under the
transparent textarea, so it is a band placed on the line's own geometry —
never a row background, which would paint over the caret layer. */
.ce-band { position:absolute; left:0; right:0; min-width:100%; z-index:0; pointer-events:none;
background:var(--add-bg); box-shadow:inset 2px 0 0 var(--add); }
/* A run of removed lines owns no line here, so its rule goes on the edge of the
line that took its place. */
.ce-gaprule { position:absolute; left:0; right:0; min-width:100%; height:2px; z-index:0; pointer-events:none; background:var(--del); }
/* The number of a changed line, in both layouts: a span in the scrolled gutter
column, the counter on the line block once the buffer wraps. */
.ce-gutter .chg { color:var(--add); }
.ce-line.chg::before { color:var(--add); }
/* The hover reveal: the whole original file over the agent column, level with
the editor. It takes no pointer, so it can never break the hover that opened
it. 496px is the design width of that column plus its 2px rule. */
.peek {
position:absolute; top:0; right:0; bottom:0; width:496px; z-index:40;
display:flex; flex-direction:column; overflow:hidden; pointer-events:none;
background:var(--bg-0); border-left:2px solid var(--del); box-shadow:var(--shadow-menu);
}
/* The same height as the view strip, so line one of both panes shares a y. */
.peek-head {
height:var(--bar-h); flex:0 0 var(--bar-h); display:flex; align-items:center; gap:12px;
padding:0 14px; background:var(--bg-2); border-bottom:1px solid var(--border);
}
.peek-head .ph-label { font-family:var(--mono); font-weight:700; font-size:10px; line-height:1;
letter-spacing:.14em; text-transform:uppercase; color:var(--del); }
.peek-head .ph-note, .peek-head .ph-hint { font-family:var(--mono); font-size:11px; line-height:1; color:var(--fg-3); }
.peek-head .ph-hint { margin-left:auto; }
.peek-body { flex:1; min-height:0; display:flex; flex-direction:column; }
/* Amber is the previous state, so the line the hovered one replaced reads one
step below the current text. */
.peek-body .ln-row.del .ln-code { color:var(--fg-1); }
/* syntax token colors — applied to both the read-only line views (.ln-code)
* and the editable buffer's highlight layer (.ce-pre) */
.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important,.ce-pre .token.keyword,.ce-pre .token.rule,.ce-pre .token.atrule,.ce-pre .token.important{color:var(--t-key);}

View File

@@ -78,8 +78,12 @@ export interface ViewLine {
text: string
sign?: string
row?: 'add' | 'del' | 'bar-add' | 'bar-del' | null
/** A run of removed lines has no line of its own on the current side, so its
* rule sits on an edge of the line that took its place. */
gap?: 'above' | 'below' | null
}
/** View a changed file opens in. 'diff' is the full-screen side-by-side overlay. */
export type DiffMode = 'original' | 'updated' | 'diff'
/** Soft wrap of long lines: never, always, or only in Markdown files. */
export type WordWrap = 'off' | 'on' | 'markdown'
@@ -126,7 +130,7 @@ export interface HelderConfig {
export const DEFAULT_CONFIG: HelderConfig = {
ai: { command: 'claude', autoLaunch: true },
editor: { autoSave: false, tabSize: 4, wordWrap: 'markdown' },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'diff', refreshInterval: 10000 },
git: { confirmDiscard: true, confirmStage: false, confirmUnstage: false, defaultDiffMode: 'updated', refreshInterval: 10000 },
files: { exclude: [], followGitignore: true },
terminal: {
shell: null,

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// A removed line owns no line in Actual, so it cannot carry a band. It leaves a
// rule on the edge of the line that took its place instead. The mock project has
// no such change, so this drives the renderer with a git payload of its own.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { 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'
import { installBridge, removeBridge } from './stub-bridge'
const HEAD = 'keep\ngone\ntail\n'
const DISK = 'keep\ntail\n'
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(() => {
installBridge([{ path: 'demo.txt', status: 'M', staged: false, original: HEAD, updated: DISK }], { 'demo.txt': DISK })
})
afterEach(() => {
cleanup()
localStorage.clear()
removeBridge()
})
async function openRow(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
const row = await waitFor(() => {
const r = c.querySelector<HTMLElement>('.git-row')
if (!r) throw new Error('git not ready')
return r
})
fireEvent.click(row)
await waitFor(() => expect(c.querySelector('.code-edit')).toBeTruthy())
return c
}
describe('a change that only removes a line', () => {
it('rules the edge of the line below it, and tints nothing', async () => {
const c = await openRow()
await waitFor(() => expect(c.querySelectorAll('.ce-gaprule')).toHaveLength(1))
expect(c.querySelector('.ce-band')).toBeNull()
expect(c.querySelector('.ce-gutter .chg')).toBeNull()
})
it('hands the removed line to the hover panel', async () => {
const c = await openRow()
const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll')
if (!el || !c.querySelector('.ce-gaprule')) throw new Error('rule not ready')
return el
})
// 'tail' took the place of the removed line, and sits on line 2.
fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => {
const p = c.querySelector<HTMLElement>('.peek')
if (!p) throw new Error('peek not ready')
return p
})
expect(Array.from(peek.querySelectorAll('.ln-row.del')).map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['gone'])
})
})

45
test/diff-view.test.ts Normal file
View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
import { buildDiffView } from '../src/renderer/src/editor'
import { makeDiff } from '../src/renderer/src/diff'
/** The current-side view of one text pair, plus the map the overlay reads. */
function view(original: string, updated: string): ReturnType<typeof buildDiffView> {
return buildDiffView(makeDiff('M', original, updated))
}
describe('buildDiffView', () => {
it('maps an added line to the line it replaced', () => {
const { lines, peek } = view('a\nold\nb', 'a\nnew\nb')
expect(lines.map((l) => l.text)).toEqual(['a', 'new', 'b'])
expect(lines[1].row).toBe('add')
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
expect(lines.every((l) => !l.gap)).toBe(true)
})
it('puts a pure deletion on the line that follows it', () => {
const { lines, peek } = view('a\ngone\nb', 'a\nb')
expect(lines[1].text).toBe('b')
expect(lines[1].gap).toBe('above')
expect(lines[1].row).toBeNull()
expect(peek.get(2)).toEqual({ anchor: 2, from: 2, to: 2 })
})
it('puts a deletion at end of file under the last line', () => {
const { lines, peek } = view('a\nb\ntail', 'a\nb')
expect(lines[1].gap).toBe('below')
expect(peek.get(2)).toEqual({ anchor: 3, from: 3, to: 3 })
})
it('anchors an insertion but marks nothing as removed', () => {
const { lines, peek } = view('a\nb', 'a\nNEW\nb')
expect(lines[1].row).toBe('add')
expect(peek.get(2)).toEqual({ anchor: 1, from: null, to: null })
expect(lines.every((l) => !l.gap)).toBe(true)
})
it('leaves an unchanged file without marks or peek targets', () => {
const { lines, peek } = view('a\nb\n', 'a\nb\n')
expect(peek.size).toBe(0)
expect(lines.every((l) => !l.row && !l.gap)).toBe(true)
})
})

View File

@@ -44,15 +44,72 @@ describe('Editor view modes', () => {
expect(c.querySelector('.editor .ln-row')).toBeTruthy()
})
it('Split opens a full-screen two-pane overlay and Esc collapses it', async () => {
it('a git row opens the file in Actual, not in the full-screen Diff', async () => {
const c = await openChanged()
fireEvent.click(c.querySelector('.split-btn')!)
expect(c.querySelector('.split-overlay')).toBeNull()
expect(find(c, '.seg button.on', 'Actual')).toBeTruthy()
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
})
it('Actual bands every changed line, over a buffer that stays editable', async () => {
const c = await openChanged()
// The mock rewrites six lines of UserController.php and removes none.
await waitFor(() => expect(c.querySelectorAll('.ce-band')).toHaveLength(6))
expect(c.querySelector('.ce-gaprule')).toBeNull()
expect(c.querySelectorAll('.ce-gutter .chg')).toHaveLength(6)
expect(c.querySelector('.ln-sign')).toBeNull()
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).readOnly).toBe(false)
})
it('the Diff segment opens the full-screen view, and Esc puts back the mode it covered', async () => {
const c = await openChanged()
fireEvent.click(find(c, '.seg button', 'Original')!)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeNull())
fireEvent.click(find(c, '.seg button', 'Diff')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
expect(c.querySelector('.split-pane.left')).toBeTruthy()
expect(c.querySelector('.split-pane.right')).toBeTruthy()
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeNull())
expect(find(c, '.seg button.on', 'Original')).toBeTruthy()
})
it('hovering a band in Actual reveals the original, leaving the editor hides it', async () => {
const c = await openChanged()
const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll')
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
return el
})
// Line 30 is the first rewritten line. Unwrapped, its band is arithmetic:
// a 10px top pad plus 20px per line, and the hit test reads clientY.
fireEvent.mouseMove(scroll, { clientY: 10 + 29 * 20 + 5 })
const peek = await waitFor(() => {
const p = c.querySelector<HTMLElement>('.peek')
if (!p) throw new Error('peek not ready')
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed).toHaveLength(1)
expect(removed[0].textContent).toContain("'plan' => $user->plan,")
fireEvent.mouseLeave(scroll)
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
})
it('⌘M cycles Actual → Original → Diff, and nothing else', async () => {
const c = await openChanged()
const seen: string[] = []
for (let i = 0; i < 3; i++) {
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', metaKey: true })) })
await waitFor(() => {
const on = c.querySelector('.seg button.on')?.textContent ?? ''
if (seen[seen.length - 1] === on) throw new Error('mode not changed')
seen.push(on)
})
}
// A tab opens in Actual, so the first ⌘M already leaves it.
expect(seen).toEqual(['Original', 'Diff', 'Actual'])
})
})

View File

@@ -14,59 +14,21 @@ vi.mock('../src/renderer/src/terminals', () => {
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
import { installBridge, removeBridge } from './stub-bridge'
const HEAD = 'a\nb\nc\n'
const INDEX = 'a\nSTAGED\nc\n'
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
/** Minimal preload bridge: enough for the store to boot with real git rows. */
/** Exactly what git-service returns for porcelain "MM". */
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
readDir: async () => [],
files: async () => ({ 'demo.txt': DISK }),
read: async () => DISK,
imageDataUrl: async () => '',
write: async () => {},
delete: async () => {},
create: async () => {},
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
// Exactly what git-service now returns for porcelain "MM".
load: async () => ({
branch: 'main',
changes: [
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
}),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onProjectChanged: off,
onConfigChanged: off,
onRefresh: off,
}
installBridge(
[
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
{ 'demo.txt': DISK },
)
}
beforeAll(() => {
@@ -77,12 +39,17 @@ beforeEach(stubBridge)
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
removeBridge()
})
/** Row text of the view currently on screen. */
/** Row text of the read-only view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
return Array.from(c.querySelectorAll('.editor-wrap .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
/** The two panes of the full-screen Diff, as line text. */
function splitText(c: HTMLElement, pane: 'left' | 'right'): string[] {
return Array.from(c.querySelectorAll(`.split-pane.${pane} .ln-row`)).map((el) => (el.textContent ?? '').replace(/^\d*/, ''))
}
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
@@ -104,6 +71,14 @@ async function boot(): Promise<HTMLElement> {
return c
}
/** Open one git row, then lift its pair into the full-screen Diff. */
async function openSplit(c: HTMLElement, label: 'Staged Changes' | 'Changes'): Promise<void> {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
fireEvent.click(Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Diff')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
}
describe('a file that is staged and then edited again', () => {
it('shows up in both groups', async () => {
const c = await boot()
@@ -113,31 +88,49 @@ describe('a file that is staged and then edited again', () => {
it('Diff on the staged row compares HEAD with the staged copy', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('b')
expect(text).toContain('STAGED')
// The later edit is not part of what is staged, so it must not show here.
expect(text).not.toContain('AFTER-STAGING')
await openSplit(c, 'Staged Changes')
expect(splitText(c, 'left')).toEqual(['a', 'b', 'c'])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c'])
// The later edit is not part of what is staged, so neither side may show it.
expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING')
})
it('Diff on the unstaged row compares the staged copy with disk', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('AFTER-STAGING')
await openSplit(c, 'Changes')
// 'b' was already replaced before staging, so this half must not mention it.
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
expect(splitText(c, 'left')).toEqual(['a', 'STAGED', 'c', ''])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
})
it('the hover panel behind Actual always reaches back to HEAD', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll')
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
return el
})
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => {
const p = c.querySelector<HTMLElement>('.peek')
if (!p) throw new Error('peek not ready')
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
fireEvent.mouseLeave(scroll)
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
})
it('labels which pair the diff is comparing', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
await openSplit(c, 'Staged Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged')
await openSplit(c, 'Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('staged → actual')
})
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {

63
test/stub-bridge.ts Normal file
View File

@@ -0,0 +1,63 @@
/* Minimal preload bridge for the renderer tests: real git rows over a flat file
* list. Without `window.helder` the store falls back to the mock project, so a
* test that needs its own git payload has to install one of these first. */
import { DEFAULT_CONFIG } from '../src/renderer/src/types'
import type { GitStatus } from '../src/renderer/src/types'
export interface StubRow {
path: string
status: GitStatus
staged: boolean
original: string
updated: string
}
/** Install the bridge. `files` is the working tree: one entry per path. */
export function installBridge(rows: StubRow[], files: Record<string, string>): void {
const noop = (): void => {}
const off = (): (() => void) => noop
const children = Object.keys(files).map((path) => ({ name: path, type: 'file' as const, path }))
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children }),
readDir: async () => [],
files: async () => files,
read: async (path: string) => files[path] ?? '',
imageDataUrl: async () => '',
write: async () => {},
delete: async () => {},
create: async () => {},
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
load: async () => ({ branch: 'main', changes: rows }),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
symbols: { names: async () => [], lookup: async () => ({ name: '', defs: [], refs: [], refCount: 0 }) },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onFullscreen: off,
onProjectChanged: off,
onConfigChanged: off,
onRefresh: off,
}
}
export function removeBridge(): void {
delete (window as unknown as { helder?: unknown }).helder
}