diff --git a/CHANGELOG.md b/CHANGELOG.md index ccec1294..f910ad46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to AngKorGit are documented here. The format follows ## [Unreleased] +### Fixed +- Long diff lines can be reached with a horizontal scrollbar or Shift+mouse-wheel in + inline and side-by-side views, including changes opened from a selected commit. The + scrollbar stays visible while scrolling vertically. + ## [0.20.0] — 2026-09-30 The keyboard release. → walks through the changes of an open diff, ⌘1 to ⌘9 and diff --git a/CLAUDE.md b/CLAUDE.md index c2399b15..78f74c73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2897,7 +2897,10 @@ update CLAUDE.md or docs/ — never the code. Playwright's `webkit`, add the synthetic file to demo.ts TEMPORARILY, sample rAF gaps while dispatching `page.mouse.wheel`, and put the mouse over the VISIBLE scroller rect — the diff auto-jump scrolls the pane, so a pane-relative y lands - off screen and every event silently misses. + off screen and every event silently misses. A sticky horizontal scrollbar proxies + the pan offset in inline and split views (both split panes stay synchronized); + Shift+wheel also pans. Keep this bar inside the virtual diff root so it stays at + the viewport bottom during vertical scrolling. - **G47 — a DOM selection does not survive virtualized rows; keep it in data space**: the diff rows are absolutely positioned and unmount as they leave the overscan @@ -3022,7 +3025,7 @@ update CLAUDE.md or docs/ — never the code. | Rust integration (86) | `apps/desktop/src-tauri/tests/git_engine.rs` | stage/commit/history, amend, branch/merge(ff+normal+conflict+message), branch-over-tag ref resolution (merge/rebase/history filter), ff-merge preserving uncommitted changes, drag-merge sequence (checkout target → merge source), no-ff merge commit when ff possible, can-fast-forward only when strictly behind, merge message available only during conflicted merge, interactive rebase (reorder/drop + range listing, squash/reword, conflict aborts untouched, invalid-plan rejection), file history lists only touching commits + paginates with skip, conflict resolve, stash (whole tree + selected paths incl. an untracked-only pick, a staged pick that leaves other staged files alone and pops with a dirty index, a staged-new pick removed and restored, git CLI agreeing on stash list/show; stash_files lists tracked + untracked entries with source_oid; stash_restore_files restores chosen files incl. an untracked one and a deletion, keeps the stash, rejects unknown paths), remote-branch checkout (stale local fast-forwarded with an untracked file preserved + upstream set, diverged local kept), tags, cherry-pick (plain keeps the message verbatim; record-origin output byte-equal to `git cherry-pick -x` for plain and trailer-block messages; many: in-order with per-commit origin lines, first conflict stops the run with progress counts), revert, reset (+ unknown-mode error), history pagination with and without filters, history position lookup by full + short hash, history search positions (case-insensitive text, hash prefix, none, blank, author alone, text + author, unknown author), stashes listed in the default walk as single-parent commits with a stash decoration while their index/untracked helper commits stay hidden and a branch-filtered walk omits them, broken-symlink staging (unix), diff hunks + whole-file context, unstage_all/discard_all, discard_staged_file/all (HEAD restored for index + worktree, staged-new file deleted, other staged files untouched), line+hunk ops on files without trailing newline, git-CLI interop, commit signing (SSH sign verified via `git verify-commit`, unsigned without config, amend re-signs, merge commit signed, failure blocks the commit and leaves HEAD/index untouched), PR checkout (fork-style refs/pull fetch creates + updates the local branch and re-runs cleanly, diverged local branch refused with HEAD untouched, same-repo tracking checkout sets the upstream), ref fingerprint (tracks refs/HEAD, ignores plain file edits), commit file lists (per-file counts without hunks), single-file commit diff scoped by pathspec, worktrees (add lists + checks out the branch and reports is_worktree/main_path from inside the linked folder, new branch from a base commit + duplicate refused, branch held elsewhere refused, checkout of a held branch refused with HEAD untouched, dirty remove refused unless forced, prune of a deleted folder, ref fingerprint changes on add, `git worktree list --porcelain` reports the engine-created worktree and `git status` runs clean inside it), discover refuses a missing path instead of walking to the parent repo, push reports up_to_date without contacting the remote when the tip equals the tracking ref while `git push` prints Everything up-to-date, and pushes again after a new commit, push with tags lands a local tag on a bare remote through named refspecs, pull with mode rebase / configured pull.rebase keeps history linear while mode merge creates a merge commit, blame attributes lines to their commits and marks an uncommitted edit, blames at a revision and at `^`, an uncommitted edit inside a committed block keeps the author on both split halves (the G44 crash), untracked/staged-new files and paths missing from a commit get explicit errors, staging and unstaging a later hunk of a multi-hunk file leaves the other hunk alone, remote_add registers a remote that list and fetch then use while a duplicate or blank name is refused, reword of HEAD changes only the message (tree + author lines byte-equal, dirty worktree and staged file untouched), reword of an earlier commit rewrites the commits above it and returns the reworded commit's new oid while a dirty tree is refused, unpushed lists only commits missing from every refs/remotes ref, tree_files lists every path at a commit while commit_files still lists the two changed ones, index_files lists tracked and staged-new paths but no untracked file, file_contents reads a file at a commit and on disk as context lines with status unchanged, errors for a path missing from the commit and flags a NUL-byte file binary | | Rust module (75) | `apps/desktop/src-tauri/src/cli.rs` (7), `src/editors.rs` (4), `src/fonts.rs` (1), `src/ai_cli.rs` (10), `src/error.rs` (7), `src/core/remote.rs` (16), `src/terminal.rs` (3), `src/core/accounts.rs` (7), `src/core/sign.rs` (7), `src/forge.rs` (7), `src/proc.rs` (1), `src/watcher.rs` (4) | AI-CLI runner: program allowlist (incl. Cursor's `agent` and `cursor-agent` names), stdout capture via fake agent script, {OUTPUT_FILE} substitution, kill-on-timeout, Cursor version-folder keys and the .cmd launcher → newest node.exe/index.js resolution (none for other agents or a missing versions dir) · error mapping: HTTP status extraction from libgit2 messages, 401/402/403 explanations, unmapped codes kept verbatim, io NotFound → not_found while other io errors stay io · forge proxy: api-subdomain host allowlist (dot-anchored), per-provider auth headers, bitbucket email requirement, unknown provider rejected, missing-token vs no-account message · SSH key resolution: `~` expansion, configured key ordered ahead of defaults, dedupe when the configured key IS a default, blank config ignored, generation never targeting an existing key · push refspec shapes (plain/force/tags never forced) · repo account-binding parse (valid/malformed) · accounts: upsert keeps both same-host accounts + default flags, one default per host, preferred-before-default candidate order, port-loose host match, ssh URLs ignored · signing config: off by default, ssh setup read from git config, empty-string values read as unset, ssh-without-key and x509 are clear errors, openpgp falls back to the committer identity, literal-key detection, ~ expansion · proc: no bare `Command::new` anywhere outside proc.rs (G31) · watcher: metadata filter unchanged for main repos, `worktrees/*/HEAD|gitdir|locked` relevant while `worktrees/*/index` is noise, a linked worktree watches its own gitdir + the shared commondir, main repos need no extra roots | | Unit (227) | `tests/unit/*.test.ts` | GraphLayout (incl. pagination stability, lane reuse), wordDiff (round-trip), conflict parse/serialize (diff3 labels, CRLF, bare markers, 8+-char content lines, close-without-separator — all lossless), cliAgents (per-agent argv/stdin shape incl. Cursor print mode with the prompt last, ANSI/OSC cleaning, output-file preference, error surfacing), aiProviders (empty/whitespace/missing content rejected for openai-compatible + ollama, HTTP status+body surfaced, real content passes), aiCapabilities (review conventions: absent by default, general-only, general+project order with precedence note, whitespace = absent, clipping), aiTextSegments (token parse: adjacent tokens, unclosed/inner-asterisk/multi-line markers stay literal, ** inside backticks is code), reviewSignature (staged-only, order-insensitive, unstaged-edit + set changes alter it, newline filenames don't collide, hashText determinism), commitStyle (prefix rule matching/tokens/ticket-fallthrough, `$`-sequence literalness, preset instructions, post-generation prefix enforcement), pullRequestUrl (https/scp/ssh remotes, non-standard ports kept, http preserved, ssh port dropped, Bitbucket Server /scm/ shape, .git-behind-slash strip, unknown forge → null), aiModels (per-provider list endpoints/headers incl. Groq-style base URLs, generateContent filtering for Gemini, dedupe/sort, invalid-JSON + HTTP-status errors, cli → empty without a request), forge (parseForgeRemote for all three forges + rejects, provider creation gating, github/gitlab/bitbucket adapters: request URLs, field mapping incl. fork + draft detection, create payloads, error-message surfacing incl. github field-level validation entries without a message, checkoutSpec shapes incl. bitbucket fork → null, pickForgeRemote upstream-first ordering, gitlab self-hosted https→http transport fallback — GET-only (a retried POST could file a duplicate MR), never for gitlab.com or api-level errors, working scheme remembered per host and safe under concurrent fallbacks, reviewer candidates per forge + reviewer ids embedded/requested on create incl. github follow-up failure tolerance, authorAvatar per forge: github commit author, gitlab avatar-by-email URL encoding, bitbucket commit author user), worktree (folder-name slug from repo + branch, sibling-path suggestion incl. Windows separators, parentDirectory), highlight (block-comment continuation: JSDoc carried and closed, line comments and glob strings not treated as open, mid-line close resumes code, xml opener, no-block-comment languages ignore the flag; embedded languages: astro on the xml grammar, svelte/toml mappings, whole-file frontmatter + script/style blocks incl. lang="ts"/"scss" and a single-line script tag, hunk walk from line 1 and mid-file back to the first fence or closer, template-only hunk marks nothing, non-markup → null), commitMessage (split/join round-trip, CRLF, second line without blank line is body, newline never enters the summary), fileFilter (empty/whitespace query passes all, case-insensitive substring, every term required), webUrl (https/scp/ssh:// remotes → repository page, ssh port dropped, http + web port kept, Bitbucket Server browse page, Bitbucket Cloud untouched, trailing slash, local path → null), blameable (hasCommittedHistory true for every tracked change kind, false for untracked and staged-new), renderCap (lines at or under 5,000 chars stay whole, longer ones clip and count the hidden tail, custom cap), fork → upstream pull requests (github head `owner:branch`, gitlab GET target id then POST on the source project with target_project_id, bitbucket source.repository, forgeTargets dedupes same-host remotes and defaultForgeTarget prefers `upstream`), allFiles (tree paths marked with their change, deleted files kept, no duplicate paths, foldersWithChanges lists every ancestor of a changed file and nothing otherwise), fetchRemotes (continues after a failure and reports failures), patchText (headers + hunk headers + prefixed lines, rename old path, multi-file join, hasReviewableText rejects null/binary/image/empty), single-file review + explain prompts (file/where/sibling context lines, sibling cap at 30, section labels, conventions order, patch at the end, 4096-token budget), aiReport (labelled sections, bullet severities, continuation lines, verdict tones, unstructured fallback, bold inside a sentence is not a label), locateDiffLine (exact match prefers additions, deletion/context fallback, whitespace-insensitive whole-line hit, tiny/absent snippets null), commit review + explain prompts (commit line, file list capped at 30, conventions, patch at the end), terminalKeys (Alt+Up/Down map to ESC[1;3A/B on keydown only, every other modifier/key/phase left to xterm), chord (key from event.code so ⌥A stays A, unsupported keys null, id round-trip, exact modifier match, per-platform labels, no-modifier/reserved problems per platform, repoForChord lookup) | -| E2E (94) | `tests/e2e/smoke.spec.ts` | splash→welcome, the AI chip names Ollama on the demo defaults, reads Set up AI when the CLI provider has no agent, Claude Code once picked, turns ok after Test connection, stale when the provider changes back to Ollama, and survives a reload, Blame is disabled in the file menu and the diff header for the untracked demo file while a tracked file keeps it enabled, the remote row menu's Open in browser opens the demo remote's GitHub page in a popup and the palette lists Open repository in browser, the sidebar and graph branch menus carry a fast-forward entry next to merge that is disabled while the current branch is ahead, settings installs the command line tool, settings lists the demo editors and picking Zed relabels the toolbar button and its menu, the Pull options menu offers merge and rebase, the status bar reports Fetched just now after the auto fetch, retries every remote after a partial auto fetch failure, keeps the timestamp and faint failed remote hint through partial and offline fetches, updates the timestamp on Pull, and explains the disabled Fetch button when no remotes exist, the diff header's Blame button opens file history in the blame pane with the Working copy row selected, per-hunk author buttons and an uncommitted hunk that disappears when a commit row is picked, a Blame at this commit menu, the Diff view toggle brings the diff controls back and Escape returns to the graph, the palette's Blame… picks a file and opens its blame pane, open repo, graph, inspector, palette, search, conflict resolver line picks, single-conflict nav + per-conflict take-all, per-block conflict hand edit, interactive rebase dialog + multi-select squash, cherry-pick dialog with the source-reference checkbox on by default, multi-select cherry-pick listing both commits and confirming, diff auto-jump lands at the first change with no scroll animation (frame-traced scrollTop), long path stays inside the discard confirm dialog, commit action buttons stay inside a narrow working copy panel (the row wraps), sidebar branch names align with and without the HEAD tick (measured left offsets), hovering a working-copy file reveals its full path, opening a diff folds the sidebar away and the toggle brings back the graph, avatars stay visible after a diff open/close round-trip (stubbed Gravatar), diff text selection survives the right-click copy menu and Esc closes only the menu, sidebar lists the demo pull requests and the create dialog opens, reconnecting an account opens the prefilled token form with focus in the token field, a file history row's Open commit button closes the panel and selects that commit in the graph, the author box finds commits with the graph lanes intact and combines with the text search, commit search finds matches in the full graph (n of m pill, Enter/Previous step, query survives selecting another commit, Escape clears), searching a commit hash jumps to it in the full graph, a short hash prefix jumps too while an unknown hex word says No matches, a missing hash keeps the graph with No matches, mod+f focuses the commit search, sidebar lists the demo worktrees (incl. a missing-folder row) and the new-worktree dialog re-suggests the folder as the branch name is typed, the inner line of a JSDoc block in the demo CommitGraph.tsx diff renders as one hljs-comment span (guards the DiffViewer → prepareCommentStates wiring, which the unit tests cannot see), collapse-all folds every section and branch folder and a reopened section comes back with its folders closed, the inspector keeps its width (±1px) when a diff folds the sidebar away and both widths return on Escape (measured via data-panel-id after dragging the sidebar handle), the commit box renders the summary larger and bolder than the description with Enter/Backspace moving between them and commit disabled on an empty summary, dragging the commit box's top edge grows the description and double-click resets it, the Changes header's fold button collapses that list's folders (nested file hidden) and flips to expand-all, and no fold button exists in flat mode, the top graph row shows a whole `main` chip with no HEAD chip and no clipped chip text plus a 7-char hash copy button, the graph display menu hides the hash column and brings it back, the sidebar accordion pins collapsed headers at the bottom under an open Branches section and moves the Tags header up when Tags opens, the welcome page flags the demo's missing folder and opens a repository via ↓ + ⏎ from the autofocused search, the conflict resolver renders positive integer line numbers in A, B and Result with each starting at 1, the checked-out branch chip is opaque while another local branch's chip stays translucent, double-clicking the separated `origin/main` chip opens the reset prompt naming the 2 commits that would be lost and Cancel dismisses it (the demo repo puts origin/main 2 commits behind main so the split is real), the diff header's History button opens that same file's history panel and closes the diff, a working-copy row's "Stash this file…" opens the dialog naming that file and the toolbar pop button restores the latest demo stash, shift-click selects three working-copy rows and the bulk menu offers Stage 3 / Stash 3 files, the working-copy filter hides non-matching rows with "1 of 2" counts and the clear button restores them, the commit file filter narrows five demo files to one and Escape resets it, selecting the demo stash shows the stash hint and the hover restore button on a file toasts the restore, right-clicking a plain commit's file offers File history / Show in Finder / Copy path and no stash Apply entry, staged rows offer discard from the row, the menu and the header trash icon, the sidebar is visible again after a reload that happened with a diff open, the demo stash renders as a graph row with an Archive node and a dashed chip whose menu pops it, ↓ then → opens the second commit's first file with the file list focused, ↓/↑ walk files, twelve → presses stay on the same file and one ← returns to the graph on the same commit, the Graph display menu's "Lane color band" removes and restores the tail rects, the inspector stops at its minimum width when dragged and folds/returns for file history, dragging the sidebar shut and back open in one gesture shows its content again, conflict picks land in file order with a mixed-state side checkbox, the resolver picks with A/B/↑/↓ and ⌘⏎ opens the next conflicted file, leaving with picks asks first while Escape closes a clean resolver, right-clicking the main tip row offers Push main ↑2 while an inner commit's menu has no push, hovering the feature/diff-viewer chip stacks both its refs in a panel whose first chip sits on the row chip (≤2px) with the folded `release/0.4` under it, and right-clicking that one opens its Checkout menu (no reset entry for a plain local), the top row shows `main` as its visible chip although `hotfix/lane-colors` comes first in the refs and only `main` carries the check inside the stacked panel, right-clicking the separated `origin/main` chip offers "Reset main to this…" and opens the same reset dialog, clicking a working-copy file then ↓/↓/↑ moves the diff through ipc.ts → palette-seed.sql → Architecture.md and back — the Remotes header's Add remote button opens the add dialog with the button disabled until name and URL are filled, right-clicking the terminal shows Copy (disabled without a selection)/Paste/Select all/Clear terminal, Settings → Git → Clone destination remembers a chosen folder and the clone dialog's destination starts there, a four-row drag selection in the palette-seed diff stays a Range and still copies all four lines via ⌘C after its rows scroll out of the virtualizer in either direction, and reads the same text once scrolled back, the pushed demo commit opens the editor and Save asks "Rewrite a pushed commit?" (Cancel keeps the draft) while the top commit opens the inline editor on double-click (Esc cancels, Save rewrites the heading, body and graph row), the GitHub account form keeps the classic token link and adds the fine-grained page inline while the Token label still focuses the input, the Fonts card's Interface picker lists proportional fonts first and Code lists monospace first, picking Helvetica Neue/Fira Code changes --font-sans/--font-mono and the terminal follows Code until Menlo + 16 px is picked for it, all of it survives a reload and Reset fonts clears it, sidebar section headers and bodies carry no fill and no top border while each header shows a filled gold icon tile and a filled count badge, the commit Files header's A token narrows five files to one with All pressed off and back, the All files view lists Roadmap.md at the commit with 5 changed, keeps a change-free folder collapsed until clicked, opens the unchanged file with an unchanged badge and no @@ header while Folder tree hides it again, the working copy's All files view shows README.md beside the stage/unstage checkboxes of the changed files and opens it without a Stage file button, dragging a selection below the diff grows it past six lines from the start row and ⌘C copies the same text before and after scrolling away, the diff header's AI actions menu reviews the open file (busy strip → demo response under the header, Copy, fold to one line and back, full-view dialog, button disabled meanwhile, closing and reopening the diff dismisses the result) then explains it and Dismiss removes the panel, the inspector's Review with AI reviews the selected commit (Stop reviewing while busy, AI review card → demo response, Dismiss), file history's diff pane shows no change arrows on a commit that did not touch palette-seed.sql and, on the Working copy row, `n` jumps the scroller to the one change, `p` from the top wraps to it and the header's Next change button lands on the same offset, a second repo opened from the palette becomes a tab that ⌘1 / ⌘⇧] switch between (wrapping), the tab menu's Keyboard shortcut… capture box flags a bare `t` as no_modifier and ⌘K as reserved without opening the palette, saving ⌃⇧T shows the chip on the tab and the chord switches to it, and Settings → Shortcuts lists and removes it, and Force push from the Push menu opens a "Force push?" confirm naming origin/main that Cancel dismisses without pushing — all on demo mode | +| E2E (96) | `tests/e2e/smoke.spec.ts` | splash→welcome, the AI chip names Ollama on the demo defaults, reads Set up AI when the CLI provider has no agent, Claude Code once picked, turns ok after Test connection, stale when the provider changes back to Ollama, and survives a reload, Blame is disabled in the file menu and the diff header for the untracked demo file while a tracked file keeps it enabled, the remote row menu's Open in browser opens the demo remote's GitHub page in a popup and the palette lists Open repository in browser, the sidebar and graph branch menus carry a fast-forward entry next to merge that is disabled while the current branch is ahead, settings installs the command line tool, settings lists the demo editors and picking Zed relabels the toolbar button and its menu, the Pull options menu offers merge and rebase, the status bar reports Fetched just now after the auto fetch, retries every remote after a partial auto fetch failure, keeps the timestamp and faint failed remote hint through partial and offline fetches, updates the timestamp on Pull, and explains the disabled Fetch button when no remotes exist, the diff header's Blame button opens file history in the blame pane with the Working copy row selected, per-hunk author buttons and an uncommitted hunk that disappears when a commit row is picked, a Blame at this commit menu, the Diff view toggle brings the diff controls back and Escape returns to the graph, the palette's Blame… picks a file and opens its blame pane, open repo, graph, inspector, palette, search, conflict resolver line picks, single-conflict nav + per-conflict take-all, per-block conflict hand edit, interactive rebase dialog + multi-select squash, cherry-pick dialog with the source-reference checkbox on by default, multi-select cherry-pick listing both commits and confirming, diff auto-jump lands at the first change with no scroll animation (frame-traced scrollTop), long path stays inside the discard confirm dialog, commit action buttons stay inside a narrow working copy panel (the row wraps), sidebar branch names align with and without the HEAD tick (measured left offsets), hovering a working-copy file reveals its full path, opening a diff folds the sidebar away and the toggle brings back the graph, avatars stay visible after a diff open/close round-trip (stubbed Gravatar), diff text selection survives the right-click copy menu and Esc closes only the menu, sidebar lists the demo pull requests and the create dialog opens, reconnecting an account opens the prefilled token form with focus in the token field, a file history row's Open commit button closes the panel and selects that commit in the graph, the author box finds commits with the graph lanes intact and combines with the text search, commit search finds matches in the full graph (n of m pill, Enter/Previous step, query survives selecting another commit, Escape clears), searching a commit hash jumps to it in the full graph, a short hash prefix jumps too while an unknown hex word says No matches, a missing hash keeps the graph with No matches, mod+f focuses the commit search, sidebar lists the demo worktrees (incl. a missing-folder row) and the new-worktree dialog re-suggests the folder as the branch name is typed, the inner line of a JSDoc block in the demo CommitGraph.tsx diff renders as one hljs-comment span (guards the DiffViewer → prepareCommentStates wiring, which the unit tests cannot see), collapse-all folds every section and branch folder and a reopened section comes back with its folders closed, the inspector keeps its width (±1px) when a diff folds the sidebar away and both widths return on Escape (measured via data-panel-id after dragging the sidebar handle), the commit box renders the summary larger and bolder than the description with Enter/Backspace moving between them and commit disabled on an empty summary, dragging the commit box's top edge grows the description and double-click resets it, the Changes header's fold button collapses that list's folders (nested file hidden) and flips to expand-all, and no fold button exists in flat mode, the top graph row shows a whole `main` chip with no HEAD chip and no clipped chip text plus a 7-char hash copy button, the graph display menu hides the hash column and brings it back, the sidebar accordion pins collapsed headers at the bottom under an open Branches section and moves the Tags header up when Tags opens, the welcome page flags the demo's missing folder and opens a repository via ↓ + ⏎ from the autofocused search, the conflict resolver renders positive integer line numbers in A, B and Result with each starting at 1, the checked-out branch chip is opaque while another local branch's chip stays translucent, double-clicking the separated `origin/main` chip opens the reset prompt naming the 2 commits that would be lost and Cancel dismisses it (the demo repo puts origin/main 2 commits behind main so the split is real), the diff header's History button opens that same file's history panel and closes the diff, a working-copy row's "Stash this file…" opens the dialog naming that file and the toolbar pop button restores the latest demo stash, shift-click selects three working-copy rows and the bulk menu offers Stage 3 / Stash 3 files, the working-copy filter hides non-matching rows with "1 of 2" counts and the clear button restores them, the commit file filter narrows five demo files to one and Escape resets it, selecting the demo stash shows the stash hint and the hover restore button on a file toasts the restore, right-clicking a plain commit's file offers File history / Show in Finder / Copy path and no stash Apply entry, staged rows offer discard from the row, the menu and the header trash icon, the sidebar is visible again after a reload that happened with a diff open, the demo stash renders as a graph row with an Archive node and a dashed chip whose menu pops it, ↓ then → opens the second commit's first file with the file list focused, ↓/↑ walk files, twelve → presses stay on the same file and one ← returns to the graph on the same commit, the Graph display menu's "Lane color band" removes and restores the tail rects, the inspector stops at its minimum width when dragged and folds/returns for file history, dragging the sidebar shut and back open in one gesture shows its content again, conflict picks land in file order with a mixed-state side checkbox, the resolver picks with A/B/↑/↓ and ⌘⏎ opens the next conflicted file, leaving with picks asks first while Escape closes a clean resolver, right-clicking the main tip row offers Push main ↑2 while an inner commit's menu has no push, hovering the feature/diff-viewer chip stacks both its refs in a panel whose first chip sits on the row chip (≤2px) with the folded `release/0.4` under it, and right-clicking that one opens its Checkout menu (no reset entry for a plain local), the top row shows `main` as its visible chip although `hotfix/lane-colors` comes first in the refs and only `main` carries the check inside the stacked panel, right-clicking the separated `origin/main` chip offers "Reset main to this…" and opens the same reset dialog, clicking a working-copy file then ↓/↓/↑ moves the diff through ipc.ts → palette-seed.sql → Architecture.md and back — the Remotes header's Add remote button opens the add dialog with the button disabled until name and URL are filled, right-clicking the terminal shows Copy (disabled without a selection)/Paste/Select all/Clear terminal, Settings → Git → Clone destination remembers a chosen folder and the clone dialog's destination starts there, a four-row drag selection in the palette-seed diff stays a Range and still copies all four lines via ⌘C after its rows scroll out of the virtualizer in either direction, and reads the same text once scrolled back, the pushed demo commit opens the editor and Save asks "Rewrite a pushed commit?" (Cancel keeps the draft) while the top commit opens the inline editor on double-click (Esc cancels, Save rewrites the heading, body and graph row), the GitHub account form keeps the classic token link and adds the fine-grained page inline while the Token label still focuses the input, the Fonts card's Interface picker lists proportional fonts first and Code lists monospace first, picking Helvetica Neue/Fira Code changes --font-sans/--font-mono and the terminal follows Code until Menlo + 16 px is picked for it, all of it survives a reload and Reset fonts clears it, sidebar section headers and bodies carry no fill and no top border while each header shows a filled gold icon tile and a filled count badge, the commit Files header's A token narrows five files to one with All pressed off and back, the All files view lists Roadmap.md at the commit with 5 changed, keeps a change-free folder collapsed until clicked, opens the unchanged file with an unchanged badge and no @@ header while Folder tree hides it again, the working copy's All files view shows README.md beside the stage/unstage checkboxes of the changed files and opens it without a Stage file button, dragging a selection below the diff grows it past six lines from the start row and ⌘C copies the same text before and after scrolling away, the diff header's AI actions menu reviews the open file (busy strip → demo response under the header, Copy, fold to one line and back, full-view dialog, button disabled meanwhile, closing and reopening the diff dismisses the result) then explains it and Dismiss removes the panel, the inspector's Review with AI reviews the selected commit (Stop reviewing while busy, AI review card → demo response, Dismiss), file history's diff pane shows no change arrows on a commit that did not touch palette-seed.sql and, on the Working copy row, `n` jumps the scroller to the one change, `p` from the top wraps to it and the header's Next change button lands on the same offset, a second repo opened from the palette becomes a tab that ⌘1 / ⌘⇧] switch between (wrapping), the tab menu's Keyboard shortcut… capture box flags a bare `t` as no_modifier and ⌘K as reserved without opening the palette, saving ⌃⇧T shows the chip on the tab and the chord switches to it, and Settings → Shortcuts lists and removes it, and Force push from the Push menu opens a "Force push?" confirm naming origin/main that Cancel dismisses without pushing — the long-line diff scrollbar stays at the viewport bottom, pans both split panes together, accepts arrow keys and Shift+wheel, and disappears when wrapping is enabled, the code diff opened from a selected commit also scrolls horizontally in inline and split views, all on demo mode | ## 9.5 Open-source & community files diff --git a/apps/desktop/src/features/diff/VirtualDiff.tsx b/apps/desktop/src/features/diff/VirtualDiff.tsx index ac1c9984..c635dfb9 100644 --- a/apps/desktop/src/features/diff/VirtualDiff.tsx +++ b/apps/desktop/src/features/diff/VirtualDiff.tsx @@ -265,6 +265,7 @@ function useHorizontalPan( panes: React.RefObject[], layers: React.RefObject[], width: number, + scrollbarRef: React.RefObject, ) { const x = useRef(0); useEffect(() => { @@ -277,7 +278,14 @@ function useHorizontalPan( for (const layer of layers) { if (layer.current) w = Math.max(w, layer.current.scrollWidth); } - return Math.max(0, w - pane.clientWidth); + const limit = Math.max(0, w - pane.clientWidth); + const scrollbar = scrollbarRef.current; + if (scrollbar?.firstElementChild) { + (scrollbar.firstElementChild as HTMLElement).style.width = `${scrollbar.clientWidth + limit}px`; + scrollbar.style.height = limit > 0 ? '12px' : '0px'; + scrollbar.tabIndex = limit > 0 ? 0 : -1; + } + return limit; }; const maxX = () => { if (limit === null) limit = measureLimit(); @@ -285,7 +293,10 @@ function useHorizontalPan( }; const apply = () => { raf = 0; - x.current = Math.min(x.current, maxX()); + const limit = maxX(); + const scrollbar = scrollbarRef.current; + x.current = Math.min(x.current, limit); + if (scrollbar && scrollbar.scrollLeft !== x.current) scrollbar.scrollLeft = x.current; for (const layer of layers) { if (layer.current) layer.current.style.transform = `translateX(${-x.current}px)`; } @@ -295,14 +306,22 @@ function useHorizontalPan( apply(); }; const onWheel = (e: WheelEvent) => { - if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; // vertical → outer scroller - const dx = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX; + const shifted = e.shiftKey && e.deltaX === 0; + if (!shifted && Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + const delta = shifted ? e.deltaY : e.deltaX; + const dx = e.deltaMode === 1 ? delta * 16 : delta; const next = Math.min(Math.max(0, x.current + dx), maxX()); e.preventDefault(); if (next === x.current) return; x.current = next; if (!raf) raf = requestAnimationFrame(apply); }; + const onScroll = () => { + x.current = scrollbarRef.current?.scrollLeft ?? 0; + apply(); + }; + const scrollbar = scrollbarRef.current; + scrollbar?.addEventListener('scroll', onScroll); const els = panes.flatMap((p) => (p.current ? [p.current] : [])); const observer = new ResizeObserver(() => { limit = null; @@ -317,12 +336,29 @@ function useHorizontalPan( return () => { if (raf) cancelAnimationFrame(raf); observer.disconnect(); + scrollbar?.removeEventListener('scroll', onScroll); for (const el of els) { el.removeEventListener('wheel', onWheel); panControllers.delete(el); } }; - }, [panes, layers, width]); + }, [panes, layers, width, scrollbarRef]); +} + +function HorizontalScrollbar({ scrollbarRef }: { scrollbarRef: React.RefObject }) { + return ( +
{ + if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) event.stopPropagation(); + }} + className="sticky bottom-0 z-10 h-3 shrink-0 overflow-x-auto overflow-y-hidden bg-surface" + > +
+
+ ); } function SelectionSentinel({ edge }: { edge: 'start' | 'end' }) { @@ -365,102 +401,106 @@ export function VirtualInlineDiff({ rows, language, useWordDiff, scrollRef, hunk const layerRef = useRef(null); const panes = useMemo(() => [paneRef], []); const layers = useMemo(() => [layerRef], []); - useHorizontalPan(panes, layers, width); + const scrollbarRef = useRef(null); + useHorizontalPan(panes, layers, width, scrollbarRef); useStableSelection(rows, scrollRef); return ( -
-
- {items.map((item) => { - const row = rows[item.index]; - return ( -
- {row.kind === 'line' && ( - <> - - - - {marker(row.line.kind).char} - - - )} -
- ); - })} -
- -
-
+
+
+
{items.map((item) => { const row = rows[item.index]; return (
+ > + {row.kind === 'line' && ( + <> + + + + {marker(row.line.kind).char} + + + )} +
); })}
-
- + +
+
+ {items.map((item) => { + const row = rows[item.index]; + return ( +
+ ); + })} +
+
+ + {items.map((item) => { + const row = rows[item.index]; + if (row.kind !== 'line') return null; + return ( +
onLineContextMenu(e, { line: row.line }) : undefined + } + > + + + +
+ ); + })} + +
{items.map((item) => { const row = rows[item.index]; - if (row.kind !== 'line') return null; + if (row.kind !== 'header') return null; return (
onLineContextMenu(e, { line: row.line }) : undefined - } + className="absolute left-0 w-full" + style={{ top: 0, height: item.size, transform: `translateY(${item.start}px)` }} > - - - +
); })} -
- {items.map((item) => { - const row = rows[item.index]; - if (row.kind !== 'header') return null; - return ( -
- -
- ); - })}
+
); } @@ -603,13 +643,17 @@ export function VirtualSplitDiff(props: CommonProps) { const width = useMemo(() => contentWidth(rowContents(props.rows)), [props.rows]); - useHorizontalPan(panes, layers, width); + const scrollbarRef = useRef(null); + useHorizontalPan(panes, layers, width, scrollbarRef); useStableSelection(props.rows, props.scrollRef); return ( -
- - +
+
+ + +
+
); } diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index e3f255e5..5693721d 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -1601,9 +1601,11 @@ test('the remotes section offers Add remote and opens the add dialog', async ({ await page.goto('/'); await page.getByText('angkorgit', { exact: true }).first().click(); await expect(page.getByPlaceholder('Search commits…')).toBeVisible({ timeout: 10_000 }); - const remotesHeader = page.getByRole('button', { name: /^Remotes/ }); - await remotesHeader.hover(); - await page.getByRole('button', { name: 'Add remote', exact: true }).click({ force: true }); + const remotesSection = page.locator('[data-sidebar-section-header]').filter({ + has: page.getByRole('button', { name: /^Remotes/ }), + }); + await remotesSection.hover(); + await remotesSection.getByRole('button', { name: 'Add remote', exact: true }).click(); const dialog = page.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: 'Add remote' })).toBeVisible(); await expect(dialog.getByPlaceholder('upstream')).toBeVisible(); @@ -2206,3 +2208,77 @@ test('force push from the push menu asks first', async ({ page }) => { await expect(dialog).toHaveCount(0); await expect(page.getByText('Push (force) done')).toHaveCount(0); }); + +test('long diff lines have a sticky horizontal scrollbar and support Shift+wheel', async ({ page }) => { + await page.setViewportSize({ width: 800, height: 900 }); + await page.goto('/'); + await page.getByText('angkorgit', { exact: true }).first().click(); + await page.getByText('palette-seed.sql').first().click(); + await expect(page.getByText('temple gold').first()).toBeVisible(); + const diff = page.locator('section[aria-label^="Diff for"]'); + const scrollbar = diff.getByLabel('Scroll diff horizontally'); + const scroller = diff.locator('div.overflow-y-auto'); + + for (const view of ['Inline diff', 'Side-by-side diff']) { + await diff.getByRole('button', { name: view, exact: true }).click(); + await expect(scrollbar).toBeVisible(); + expect(await scrollbar.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeGreaterThan(100); + const viewport = await scroller.boundingBox(); + const bar = await scrollbar.boundingBox(); + expect(bar!.y + bar!.height).toBeLessThanOrEqual(viewport!.y + viewport!.height + 1); + expect(bar!.y).toBeGreaterThan(viewport!.y + viewport!.height - 20); + + await scrollbar.focus(); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => scrollbar.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); + const offset = await scrollbar.evaluate((el) => el.scrollLeft); + const vertical = await scroller.evaluate((el) => el.scrollTop); + await diff.locator('[data-diff-pane]').first().evaluate((el) => { + el.dispatchEvent(new WheelEvent('wheel', { deltaY: 160, shiftKey: true, bubbles: true, cancelable: true })); + }); + await expect.poll(() => scrollbar.evaluate((el) => el.scrollLeft)).toBeGreaterThan(offset); + expect(await scroller.evaluate((el) => el.scrollTop)).toBe(vertical); + const transforms = await diff.locator('[data-diff-layer]').evaluateAll((els) => els.map((el) => (el as HTMLElement).style.transform)); + expect(new Set(transforms).size).toBe(1); + expect(transforms[0]).toMatch(/translateX\(-/); + + await scroller.evaluate((el) => { el.scrollTop = 0; }); + await expect(scrollbar).toBeVisible(); + const topBar = await scrollbar.boundingBox(); + expect(topBar!.y).toBeGreaterThan(viewport!.y + viewport!.height - 20); + } + + await diff.getByRole('button', { name: 'View options', exact: true }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Wrap long lines' }).click(); + await expect(scrollbar).toHaveCount(0); +}); + +test('the code diff opened from a selected commit scrolls horizontally in both views', async ({ page }) => { + await page.goto('/'); + await page.getByText('angkorgit', { exact: true }).first().click(); + await page.getByText('feat(graph): virtualize commit rows').first().click(); + const inspector = page.getByRole('complementary', { name: 'Inspector' }); + await inspector.getByText('CommitGraph.tsx', { exact: true }).click(); + const diff = page.locator('section[aria-label="Diff for src/features/graph/CommitGraph.tsx"]'); + await expect(inspector.getByRole('heading', { name: 'feat(graph): virtualize commit rows', exact: true })).toBeVisible(); + await expect(diff.getByRole('button', { name: 'Stage file', exact: true })).toHaveCount(0); + await page.setViewportSize({ width: 800, height: 900 }); + const scrollbar = diff.getByLabel('Scroll diff horizontally'); + + for (const view of ['Inline diff', 'Side-by-side diff']) { + await diff.getByRole('button', { name: view, exact: true }).click(); + await expect(scrollbar).toBeVisible(); + expect(await scrollbar.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeGreaterThan(100); + await scrollbar.focus(); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => scrollbar.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); + const offset = await scrollbar.evaluate((el) => el.scrollLeft); + await diff.locator('[data-diff-pane]').first().evaluate((el) => { + el.dispatchEvent(new WheelEvent('wheel', { deltaY: 160, shiftKey: true, bubbles: true, cancelable: true })); + }); + await expect.poll(() => scrollbar.evaluate((el) => el.scrollLeft)).toBeGreaterThan(offset); + const transforms = await diff.locator('[data-diff-layer]').evaluateAll((els) => els.map((el) => (el as HTMLElement).style.transform)); + expect(new Set(transforms).size).toBe(1); + expect(transforms[0]).toMatch(/translateX\(-/); + } +});