diff --git a/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md b/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md index 23913e55..32c2e6a7 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md +++ b/.lane/memory/apps/app/src/git.ts/01M53CQQMAFKZ7DCE8B12N901C-git-and-pr-actions-ask-the-c.md @@ -4,9 +4,9 @@ anchor: function useGit created: 2026-10-16T22:18:47Z norm: '1' sig: 6c6d8173da981fc6 -body_hash: d9f3d17ba0fba378 -raw_hash: 730462f8973a08c0 -vouched: 2026-10-16T22:49:12Z +body_hash: 35a4f47d1a607151 +raw_hash: 36efc7db0f7d9765 +vouched: 2026-10-22T14:35:52Z lines: 28-57 --- diff --git a/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md b/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md index cd9679a0..e40b21e7 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md +++ b/.lane/memory/apps/app/src/git.ts/01M53DE0VH97Z9N19627SJGNMR-merge-pr-is-offered-only-whe.md @@ -4,9 +4,9 @@ anchor: function useGit created: 2026-10-16T22:30:58Z norm: '1' sig: 6c6d8173da981fc6 -body_hash: d9f3d17ba0fba378 -raw_hash: 730462f8973a08c0 -vouched: 2026-10-16T22:49:12Z +body_hash: 35a4f47d1a607151 +raw_hash: 36efc7db0f7d9765 +vouched: 2026-10-22T14:35:52Z lines: 67-153 --- diff --git a/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md index f2555a79..17b11678 100644 --- a/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md +++ b/.lane/memory/apps/app/src/git.ts/01M53EFAEEC0X721792QGVQXJB-the-sidebar-and-diff-header.md @@ -4,8 +4,9 @@ anchor: function useGit created: 2026-10-16T22:49:09Z norm: '1' sig: 6c6d8173da981fc6 -body_hash: d9f3d17ba0fba378 -raw_hash: 730462f8973a08c0 +body_hash: 35a4f47d1a607151 +raw_hash: 36efc7db0f7d9765 +vouched: 2026-10-22T14:35:52Z lines: 67-156 --- diff --git a/.lane/memory/packages/channel/src/changes.ts/01M53DE0T5S2AMJEWQ1V4EHA2H-behind-counts-commits-on-the.md b/.lane/memory/packages/channel/src/changes.ts/01M53DE0T5S2AMJEWQ1V4EHA2H-behind-counts-commits-on-the.md index 2d610a27..8c61028a 100644 --- a/.lane/memory/packages/channel/src/changes.ts/01M53DE0T5S2AMJEWQ1V4EHA2H-behind-counts-commits-on-the.md +++ b/.lane/memory/packages/channel/src/changes.ts/01M53DE0T5S2AMJEWQ1V4EHA2H-behind-counts-commits-on-the.md @@ -4,8 +4,9 @@ anchor: function changes created: 2026-10-16T22:30:58Z norm: '1' sig: f55c70c150e7bf86 -body_hash: 449d72f9376e96c0 -raw_hash: 75f3d6495136245b +body_hash: 3e0b55394db271ed +raw_hash: 0530a5cc5672aff8 +vouched: 2026-10-22T14:35:52Z lines: 123-161 --- diff --git a/.lane/memory/packages/channel/src/changes.ts/01M5J0KWKZ22SCJT7QY69G1P60-fetching-stays-a-separate-op.md b/.lane/memory/packages/channel/src/changes.ts/01M5J0KWKZ22SCJT7QY69G1P60-fetching-stays-a-separate-op.md new file mode 100644 index 00000000..7cb80583 --- /dev/null +++ b/.lane/memory/packages/channel/src/changes.ts/01M5J0KWKZ22SCJT7QY69G1P60-fetching-stays-a-separate-op.md @@ -0,0 +1,12 @@ +--- +id: 01M5J0KWKZ22SCJT7QY69G1P60 +anchor: function fetchBase +created: 2026-10-22T14:35:35Z +norm: '1' +sig: 14f8df035e40ad8b +body_hash: 58a61d165604e742 +raw_hash: 74cdb20c0b6f1e61 +lines: 189-205 +--- + +fetching stays a separate op the Diff calls once a minute; the changes read polled every 2 s must never fetch diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index 8bbbf81d..aa4638b4 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-14T17:33:22Z norm: '1' sig: c905677fa4be33c1 -body_hash: ab25308bd79989e5 -raw_hash: 8691059ceba82558 -vouched: 2026-10-20T17:05:11Z +body_hash: e05cfd4b5bb4c4b3 +raw_hash: aeb6b16210ed310a +vouched: 2026-10-22T14:35:52Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index f0228724..0ea33294 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T00:30:42Z norm: '1' sig: c905677fa4be33c1 -body_hash: ab25308bd79989e5 -raw_hash: 8691059ceba82558 -vouched: 2026-10-20T17:05:11Z +body_hash: e05cfd4b5bb4c4b3 +raw_hash: aeb6b16210ed310a +vouched: 2026-10-22T14:35:52Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 352e6f9e..ad794159 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T02:34:37Z norm: '1' sig: c905677fa4be33c1 -body_hash: ab25308bd79989e5 -raw_hash: 8691059ceba82558 -vouched: 2026-10-20T17:05:11Z +body_hash: e05cfd4b5bb4c4b3 +raw_hash: aeb6b16210ed310a +vouched: 2026-10-22T14:35:52Z lines: 114-794 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index 48d28b8c..f237a86b 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T10:17:55Z norm: '1' sig: c905677fa4be33c1 -body_hash: ab25308bd79989e5 -raw_hash: 8691059ceba82558 -vouched: 2026-10-20T17:05:11Z +body_hash: e05cfd4b5bb4c4b3 +raw_hash: aeb6b16210ed310a +vouched: 2026-10-22T14:35:52Z lines: 121-817 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 5987caeb..932a16c9 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-16T22:11:35Z norm: '1' sig: c905677fa4be33c1 -body_hash: ab25308bd79989e5 -raw_hash: 8691059ceba82558 -vouched: 2026-10-20T17:05:11Z +body_hash: e05cfd4b5bb4c4b3 +raw_hash: aeb6b16210ed310a +vouched: 2026-10-22T14:35:52Z lines: 127-873 --- diff --git a/.lane/memory/packages/ui/src/components/diff/diff-view.tsx/01M5J0KWMQ752DN1YW0ZYTPCXZ-view-choices-tree-mode-live.md b/.lane/memory/packages/ui/src/components/diff/diff-view.tsx/01M5J0KWMQ752DN1YW0ZYTPCXZ-view-choices-tree-mode-live.md new file mode 100644 index 00000000..dbafb0a7 --- /dev/null +++ b/.lane/memory/packages/ui/src/components/diff/diff-view.tsx/01M5J0KWMQ752DN1YW0ZYTPCXZ-view-choices-tree-mode-live.md @@ -0,0 +1,12 @@ +--- +id: 01M5J0KWMQ752DN1YW0ZYTPCXZ +anchor: function DiffView +created: 2026-10-22T14:35:35Z +norm: '1' +sig: e0a2f472fc8fbf16 +body_hash: 40458c95dabb58e3 +raw_hash: b081565ff0307166 +lines: 368-373 +--- + +view choices (tree, mode) live outside the keyed content, which remounts whenever the file list changes diff --git a/apps/app/src/details.ts b/apps/app/src/details.ts index 7bd1023e..3cc7e9f7 100644 --- a/apps/app/src/details.ts +++ b/apps/app/src/details.ts @@ -9,6 +9,7 @@ import type { Item } from "./transcript"; export type Ask = | "create" | "commit" + | "push" | "update" | "rebase" | "review" diff --git a/apps/app/src/git.ts b/apps/app/src/git.ts index c43ebe05..20a22c21 100644 --- a/apps/app/src/git.ts +++ b/apps/app/src/git.ts @@ -32,6 +32,8 @@ function fix(pull: GithubPull, failed: GithubCheck[]) { const ASKS: Record, (pull: number) => string> = { commit: () => "Commit this lane's uncommitted work, following this repository's commit conventions. Leave out anything that doesn't belong, and don't push.", + push: () => + "Push this branch's commits to its remote, with `lane push` in a lane and `git push` otherwise, following this repository's instructions. Don't open a pull request.", update: (pull) => `Update #${pull} with this lane's work: commit what belongs in it, then push with \`lane push\`, following this repository's instructions.`, rebase: () => @@ -46,6 +48,7 @@ const ASKS: Record, (pull: number) => string> = { const PENDING: Record, Pick> = { commit: { label: "Committing", icon: IconCommit }, + push: { label: "Pushing", icon: IconPush }, update: { label: "Updating PR", icon: IconPush }, rebase: { label: "Rebasing", icon: IconBranch }, review: { label: "Addressing review", icon: IconReview }, @@ -107,6 +110,17 @@ export function useGit(channel: Listing, chat: number, user: string) { if (!live && dirty(changes)) { actions.push({ label: "Commit", icon: IconCommit, onClick: go("commit") }); } + // Not every project uses pull requests, or has its remote on GitHub. + if (!live && changes.ahead) { + const commits = `${changes.ahead} ${changes.ahead === 1 ? "commit" : "commits"}`; + actions.push({ + label: "Push", + icon: IconPush, + count: `${changes.ahead}`, + title: `${commits} its remote doesn't have`, + onClick: go("push"), + }); + } if (live && (unpushed || dirty(changes))) { actions.push({ label: "Update PR", @@ -143,7 +157,10 @@ export function useGit(channel: Listing, chat: number, user: string) { } } - const open = ready && changed && pull === null && !asked; + // A closed pull request, or a merged one the branch has moved past, leaves its work unproposed. + const past = pull?.state === "closed" + || (pull?.state === "merged" && !!changes && (pull.sha !== changes.head || dirty(changes))); + const open = ready && changed && (pull === null || past) && !asked; return { creatingPr: asked === "create", createPr: open ? () => request("create", create(false)) : undefined, @@ -168,7 +185,9 @@ export function useAsked(channel: string, busy: boolean, pull: GithubPull | null ran.current = false; return; } - if (asked === "create" && pull) return publish(channel, { asked: null }); + // A closed or merged pull request from before isn't the one Create PR asked for. + const live = pull?.state === "open" || pull?.state === "draft"; + if (asked === "create" && live) return publish(channel, { asked: null }); if (busy) ran.current = true; else if (ran.current) publish(channel, { asked: null }); }, [channel, asked, busy, pull]); diff --git a/apps/app/src/panels/diff.tsx b/apps/app/src/panels/diff.tsx index 7b00f701..798eb926 100644 --- a/apps/app/src/panels/diff.tsx +++ b/apps/app/src/panels/diff.tsx @@ -4,7 +4,7 @@ import { DiffView, type DiffViewFile } from "@ace/ui"; import type { Change, Changes } from "@ace/channel/protocol"; import type { Listing } from "@ace/host/protocol"; -import { useDetails } from "../details"; +import { publish, useDetails } from "../details"; import { useGit } from "../git"; import { host } from "../host"; @@ -14,6 +14,8 @@ type Props = { channel: Listing; chat: number; user: string; active: boolean }; type Loaded = { stamp: string; patch?: string; signature?: string; error?: string }; const POLL = 2000; +// Fetching the lane's base is a network round trip, so it runs far less often than the poll. +const FETCH = 60_000; const LIMIT = 4; export const GROUPS = { committed: "Committed", @@ -40,6 +42,7 @@ function same(a: Changes | undefined, b: Changes) { if (!a || a.cwd !== b.cwd || a.lane !== b.lane || a.base !== b.base || a.head !== b.head) { return false; } + if (a.against !== b.against || a.behind !== b.behind || a.ahead !== b.ahead) return false; if (a.files.length !== b.files.length) return false; if (JSON.stringify(a.dirs) !== JSON.stringify(b.dirs)) return false; return a.files.every((change, i) => @@ -59,7 +62,9 @@ function signature(source: string) { } function short(ref: string) { - return /^[0-9a-f]{40}$/.test(ref) ? ref.slice(0, 7) : ref; + return /^[0-9a-f]{40}$/.test(ref) + ? ref.slice(0, 7) + : ref.replace(/^refs\/(heads|remotes|tags)\//, ""); } function subscribe(listener: () => void) { @@ -116,6 +121,20 @@ export function Diff({ channel: listing, chat, user, active }: Props) { }; }, [channel, chat, active, focused, status]); + // Git actions read the details' changes, so a fetch's new behind count reaches them from here. + useEffect(() => { + if (changes) publish(channel, { changes }); + }, [channel, changes]); + + // The changes read never fetches, so Rebase could otherwise wait on something else fetching. + useEffect(() => { + if (!active || !focused || status !== "open") return; + const fetch = () => void host.channel(channel, { op: "fetch", chat }).catch(() => {}); + fetch(); + const timer = setInterval(fetch, FETCH); + return () => clearInterval(timer); + }, [channel, chat, active, focused, status]); + useEffect(() => { if (!changes || status !== "open") return; const wanted = changes.files.filter((change) => @@ -175,6 +194,7 @@ export function Diff({ channel: listing, chat, user, active }: Props) { adds: 0, dels: 0, note: `${count.toLocaleString()} files`, + count, }); } return files; @@ -199,9 +219,9 @@ export function Diff({ channel: listing, chat, user, active }: Props) { return ( numstat(await read(env, cwd, `diff -M --numstat -z ${args}`, context)) .map((change) => Object.assign(change, { state })); - const [branch, behind, committed, uncommitted, loose] = await Promise.all([ + const [branch, behind, ahead, committed, uncommitted, loose] = await Promise.all([ read(env, cwd, "rev-parse --abbrev-ref HEAD", context), against && read(env, cwd, `rev-list --count ${quote(`HEAD..${against}`)}`, context), + unpushed(env, cwd, against && range.base, context), stat(`${range.base} ${range.head}`, "committed"), stat(range.head, "uncommitted"), untracked(env, cwd, context), @@ -154,12 +174,36 @@ export async function changes( ...(name && name !== "HEAD" ? { branch: name } : {}), cwd, ...range, + ...(against ? { against } : {}), ...(Number(behind) ? { behind: Number(behind) } : {}), + ...(ahead ? { ahead } : {}), files, ...(loose.dirs.length ? { dirs: loose.dirs } : {}), }; } +/** + * Fetches a lane's base when it is a remote-tracking branch, so `behind` can count what landed + * on it. Kept apart from `changes`, which runs every Diff refresh and must stay local. + */ +export async function fetchBase( + env: ExecutionEnv, + cwd: string, + base: string | undefined, + context: Context, +) { + const ref = base?.startsWith("refs/remotes/") && base.slice("refs/remotes/".length); + if (!ref) return; + const remote = (await read(env, cwd, "remote", context)).split("\n") + .filter((name) => name && ref.startsWith(`${name}/`)) + .sort((a, b) => b.length - a.length)[0]; + if (!remote) return; + const branch = ref.slice(remote.length + 1); + await git(env, `fetch -q ${quote(remote)} ${quote(`+refs/heads/${branch}:${base}`)}`, context, { + cwd, + }); +} + /** `file` must be one `changes` listed; it is passed to git only as a quoted literal path. */ export async function patch( env: ExecutionEnv, diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 2c4c1202..b7b52272 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -50,7 +50,7 @@ import { STRONG, } from "./captions"; import { instructions } from "./context"; -import { changes, patch } from "./changes"; +import { changes, fetchBase, patch } from "./changes"; import { type Desktop, desktop } from "./desktop"; import { HANDOFF, Handoffs } from "./handoffs"; import { type Images, images } from "./images"; @@ -402,6 +402,10 @@ export class Channel { const { cwd, lane } = await this.#place(request.chat); return changes(this.#options.env(cwd), cwd, this.#options.project, lane, context); } + case "fetch": { + const { cwd, lane } = await this.#place(request.chat); + return fetchBase(this.#options.env(cwd), cwd, lane?.base, context); + } case "patch": { const { cwd, lane } = await this.#place(request.chat); const { env, project } = this.#options; diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index 0d68f7ad..a0f0bb9c 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -96,11 +96,18 @@ export type Changes = { cwd: string; base: string; head: string; + /** What the lane's base names: a full ref such as `refs/remotes/origin/main`, or a commit. */ + against?: string; /** * Commits on the lane's base, as last fetched, that the lane doesn't have yet; absent when * none or without a lane. */ behind?: number; + /** + * Commits its remote doesn't have: those past its upstream, or since its base for a lane not + * yet pushed. Absent when none or without a remote. + */ + ahead?: number; files: Change[]; /** Untracked directories too large to list file by file. */ dirs?: { dir: string; files: number }[]; @@ -235,6 +242,8 @@ export type Request = /** Resolve once the answer to a submission is placed: "done", or "unanswered" when stopped or killed. */ | { op: "wait"; submission: number } | { op: "changes"; chat?: ChatId } + /** Fetch the lane's remote base so `Changes.behind` counts what landed since; no-op otherwise. */ + | { op: "fetch"; chat?: ChatId } /** One listed file's unified diff; without `state`, its worktree against the base. */ | { op: "patch"; chat?: ChatId; file: string; state?: Change["state"] } /** Replay the chat's transcript, then stream its events until the connection closes. */ diff --git a/packages/ui/src/components/diff/diff-view.tsx b/packages/ui/src/components/diff/diff-view.tsx index 3e0b9f23..7874c983 100644 --- a/packages/ui/src/components/diff/diff-view.tsx +++ b/packages/ui/src/components/diff/diff-view.tsx @@ -38,6 +38,9 @@ type Totals = { group?: string; adds: number; dels: number; + files: number; + /** Binary files and summarized directories, which have no line counts. */ + fixed: boolean; }; type Cache = { @@ -50,6 +53,8 @@ type HeaderProps = { file: DiffViewFile; open: boolean; subtitle?: string; + /** Shown when the file is listed in more than one group, so its headers differ. */ + group?: string; onToggle: (file: DiffViewFile, open: boolean) => void; }; @@ -61,9 +66,13 @@ function total(files: DiffViewFile[]): Totals[] { let groups = new Map(); for (let file of files) { let sum = groups.get(file.group); - if (!sum) groups.set(file.group, sum = { group: file.group, adds: 0, dels: 0 }); + if (!sum) { + groups.set(file.group, sum = { group: file.group, adds: 0, dels: 0, files: 0, fixed: false }); + } sum.adds += file.adds; sum.dels += file.dels; + sum.files += file.count ?? 1; + sum.fixed ||= fixed(file); } return [...groups.values()]; } @@ -102,6 +111,21 @@ function stamp(file: DiffViewFile) { ].join("\0"); } +function plural(count: number, one: string, many: string) { + return `${count.toLocaleString()} ${count === 1 ? one : many}`; +} + +/** Paths listed in more than one group, such as a file both committed and edited since. */ +function regrouped(files: DiffViewFile[]) { + let seen = new Set(); + let repeated = new Set(); + for (let file of files) { + if (seen.has(file.file)) repeated.add(file.file); + seen.add(file.file); + } + return repeated; +} + function duplicateNames(files: DiffViewFile[]) { let counts = new Map(); for (let file of files) { @@ -251,6 +275,7 @@ function Header({ file, open, subtitle, + group, onToggle, }: HeaderProps) { let name = basename(file.file); @@ -267,7 +292,7 @@ function Header({ type="button" className="min-w-0 rounded-md text-left transition-[color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring/50 focus-visible:outline-none motion-reduce:transition-none" aria-expanded={fixed(file) ? undefined : open} - title={subtitle ?? file.file} + title={group ? `${subtitle ?? file.file} (${group})` : subtitle ?? file.file} onClick={() => onToggle(file, open)} > @@ -290,6 +315,7 @@ function Header({ ) : null} + {group ? {group} : null} {fixed(file) ? {file.note || "Binary"} : ( @@ -333,12 +359,21 @@ function Header({ ); } +type Kept = { + tree: [boolean, (update: (open: boolean) => boolean) => void]; + mode: [DiffViewMode, (mode: DiffViewMode) => void]; +}; + +/** The content remounts when the file list changes, so view choices live out here. */ function DiffView(props: DiffViewProps) { let key = useMemo(() => filesKey(props.files), [props.files]); - return ; + let tree = useState(props.defaultFileTreeOpen ?? false); + let mode = useState(props.defaultMode ?? "unified"); + return ; } function useDiffView({ + kept, files, base = "origin/main", head = "Changes", @@ -346,8 +381,6 @@ function useDiffView({ creatingPr, preparingPr, mode, - defaultMode = "unified", - defaultFileTreeOpen = false, selected, selectedKey, onSelectedChange, @@ -359,13 +392,13 @@ function useDiffView({ actions, onFileOpen, className, -}: DiffViewProps) { +}: DiffViewProps & { kept: Kept }) { let [navigation, setNavigation] = useState< { current: string; applied?: Selection; pending?: string; request?: Selection } >(() => ({ current: files[0]?.file ?? "" })); let { current } = navigation; - let [internalMode, setInternalMode] = useState(defaultMode); - let [treeOpen, setTreeOpen] = useState(defaultFileTreeOpen); + let [internalMode, setInternalMode] = kept.mode; + let [treeOpen, setTreeOpen] = kept.tree; let [collapsed, setCollapsed] = useState>(() => new Set()); let code = useRef>(null); let cache = useRef(new Map()); @@ -399,6 +432,7 @@ function useDiffView({ : files[0]?.file ?? ""; let summary = useMemo(() => total(files), [files]); let duplicates = useMemo(() => duplicateNames(files), [files]); + let repeated = useMemo(() => regrouped(files), [files]); let index = useMemo(() => new Map(files.map(file => [id(file), file])), [files]); let paths = useMemo(() => files.map(file => id(file)), [files]); let decorations = useMemo( @@ -408,7 +442,9 @@ function useDiffView({ text: fixed(file) ? file.note || "Binary" : `+${file.adds} -${file.dels}`, title: fixed(file) ? file.note || "Binary file" - : `${file.adds} additions, ${file.dels} deletions`, + : `${plural(file.adds, "addition", "additions")}, ${ + plural(file.dels, "deletion", "deletions") + }`, }]), ), [files], @@ -502,6 +538,7 @@ function useDiffView({ file={file} open={open} subtitle={context(file, duplicates)} + group={repeated.has(file.file) ? file.group : undefined} onToggle={toggleFile} /> ); @@ -547,7 +584,7 @@ function useDiffView({ }; } -function DiffViewContent(props: DiffViewProps) { +function DiffViewContent(props: DiffViewProps & { kept: Kept }) { let { files, base, @@ -612,8 +649,19 @@ function DiffViewContent(props: DiffViewProps) { className="flex shrink-0 items-center gap-2 text-xs font-medium tabular-nums" > {sum.group ? {sum.group} : null} - +{sum.adds} - -{sum.dels} + {sum.fixed + ? ( + + {plural(sum.files, "file", "files")} + + ) + : null} + {sum.fixed && !sum.adds && !sum.dels ? null : ( + <> + +{sum.adds} + -{sum.dels} + + )} ))} diff --git a/packages/ui/src/components/diff/types.ts b/packages/ui/src/components/diff/types.ts index c3c9b8d8..5bbc27af 100644 --- a/packages/ui/src/components/diff/types.ts +++ b/packages/ui/src/components/diff/types.ts @@ -16,6 +16,8 @@ type DiffViewFile = { group?: string; /** Shown instead of line counts for an entry without a diff, such as a summarized directory. */ note?: string; + /** How many files the entry stands for, such as a summarized directory's; 1 otherwise. */ + count?: number; }; type DiffViewMode = "unified" | "split"; diff --git a/packages/ui/src/components/session-details/session-details.tsx b/packages/ui/src/components/session-details/session-details.tsx index 9d155cb5..a5718a8b 100644 --- a/packages/ui/src/components/session-details/session-details.tsx +++ b/packages/ui/src/components/session-details/session-details.tsx @@ -127,7 +127,9 @@ function External( function Changes({ changes, onDiff }: { changes: DetailsChanges; onDiff?: () => void }) { const name = changes.group || "Changes"; const files = `${changes.files.toLocaleString()} ${changes.files === 1 ? "file" : "files"}`; - const label = `${name}: ${files}, ${changes.adds} additions, ${changes.dels} deletions`; + const adds = `${changes.adds} ${changes.adds === 1 ? "addition" : "additions"}`; + const dels = `${changes.dels} ${changes.dels === 1 ? "deletion" : "deletions"}`; + const label = `${name}: ${files}, ${adds}, ${dels}`; const content = ( <> @@ -318,7 +320,7 @@ export function SessionDetailsView( )} {pull && } - {pull === null && (creatingPr || onCreatePr) && ( + {(creatingPr || onCreatePr) && ( )} {pull === null && !creatingPr && !onCreatePr && (