From f0de1f4b45c48b1e6eff479ab2504d057c027bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 18 Jun 2026 12:25:16 +0100 Subject: [PATCH 01/11] Port version control panel work --- SOURCE_CONTROL.md | 215 + apps/desktop/src/electron/ElectronMenu.ts | 17 +- .../SourceControlPanelService.test.ts | 672 +++ .../SourceControlPanelService.ts | 1997 ++++++++ .../src/vcs/VcsStatusBroadcaster.test.ts | 51 + apps/server/src/vcs/VcsStatusBroadcaster.ts | 137 +- apps/server/src/ws.ts | 239 + apps/web/src/components/ChatView.tsx | 92 +- apps/web/src/components/RightPanelTabs.tsx | 38 +- .../source-control/SourceControlPanel.tsx | 4110 +++++++++++++++++ apps/web/src/contextMenuFallback.ts | 24 + apps/web/src/env.ts | 3 + apps/web/src/environmentApi.ts | 29 + .../service.threadSubscriptions.test.ts | 29 + apps/web/src/hostDisplayPreferences.ts | 99 + apps/web/src/rightPanelStore.ts | 15 +- apps/web/src/vite-env.d.ts | 3 +- packages/client-runtime/src/wsRpcClient.ts | 92 + packages/contracts/src/git.ts | 297 ++ packages/contracts/src/ipc.ts | 70 + packages/contracts/src/rpc.ts | 246 + packages/contracts/src/settings.ts | 2 +- 22 files changed, 8456 insertions(+), 21 deletions(-) create mode 100644 SOURCE_CONTROL.md create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlPanelService.ts create mode 100644 apps/web/src/components/source-control/SourceControlPanel.tsx create mode 100644 apps/web/src/hostDisplayPreferences.ts diff --git a/SOURCE_CONTROL.md b/SOURCE_CONTROL.md new file mode 100644 index 0000000000..d9b5a0acf3 --- /dev/null +++ b/SOURCE_CONTROL.md @@ -0,0 +1,215 @@ +# Version Control Panel + +## Current Status + +T3 Code includes a Git-backed Version Control surface in the right panel. The panel is scoped to the active environment and repository cwd, uses server-owned Git operations, and reuses the existing VCS status, source-control provider, and WebSocket RPC infrastructure rather than shelling out from React. + +The panel does not require an existing provider session or started server thread. Draft/new conversations can open Version Control as soon as they have project context and a repository cwd. Thread metadata updates caused by branch switching or detached checkout are routed by `ChatView`: server threads persist through `thread.meta.update`, while draft conversations update local draft thread context. + +The panel is intentionally an overview and high-level workflow surface. It focuses on current work, branch sync state, remotes, stashes, selected-file commit/stash flows, and compact branch/commit inspection. It is not a full VS Code SCM replacement and does not implement hunk-level staging. + +Primary implementation files: + +- `apps/web/src/components/source-control/SourceControlPanel.tsx` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/RightPanelTabs.tsx` +- `apps/server/src/sourceControl/SourceControlPanelService.ts` +- `apps/server/src/vcs/VcsStatusBroadcaster.ts` +- `apps/server/src/ws.ts` +- `packages/contracts/src/rpc.ts` +- `packages/contracts/src/ipc.ts` +- `packages/client-runtime/src/wsRpcClient.ts` +- `apps/web/src/environmentApi.ts` + +## Entry Points And Host Behavior + +Version Control is a singleton right-panel surface with kind `source-control`. Users open it from the existing right-panel surface picker; it is not duplicated into the main chat header, project sidebar, or conversation timeline. Availability is project/repository based: the surface is enabled when the host setting allows it, a thread or draft-thread ref exists for right-panel state, and the active project resolves to a repository cwd. + +Right-panel integration is owned by: + +- `apps/web/src/rightPanelStore.ts` +- `apps/web/src/components/RightPanelTabs.tsx` +- `apps/web/src/components/ChatView.tsx` + +The VS Code extension exposes the shared T3 Code panel through the `t3code.ui.enableSourceControlPanel` display setting. Browser and desktop hosts enable the panel by default; VS Code webviews hide it by default so it does not compete with VS Code's native Source Control view. The setting only controls visibility of the shared panel; it does not fork source-control behavior or change backend capabilities. + +## Layout + +The Version Control panel has a compact repository summary at the top and two resizable, collapsible sections: + +- `Actionable` +- `Remotes` + +`Remotes` is collapsed by default. The sections share the available panel height, each section owns its own overflow area, and section edges can be dragged to resize them relative to each other. + +The repository summary shows the current ref, upstream status, changed-file count and line stats, ahead-of-default context, and current error state. Git operation progress is shown in the action button that started the operation. There are no separate `Repository`, `Commit`, `Sync`, or `Diagnostics` sections in the current layout. + +## Live Updates + +The panel refreshes from the VCS status stream, explicit panel operations, window focus, and document visibility changes. `VcsStatusBroadcaster` also maintains ref-counted filesystem watchers per cwd while a repository is subscribed. File events are debounced, checked against Git ignore rules when possible, and only publish a new status when the working-tree fingerprint actually changes. + +This keeps externally-created changes visible without requiring a window blur/refocus cycle, while avoiding repeated no-op refreshes for gitignored files or unchanged status. + +## Actionable + +`Actionable` is the default operational overview. It lists only work that needs attention: + +- A dirty `Working tree` row, shown first and omitted when the tree is clean. +- Local branches that are local-only, ahead, behind, diverged, or otherwise require action. +- Same-name local branches that are behind likely fork branches on other remotes. +- Stashes. +- Other checked-out worktree branch labels when available. + +Fully synced local branches are omitted from `Actionable`; they remain visible under `Remotes` when they track a remote branch. + +When a repository has multiple remotes, the server checks local branches against same-name branches on other remotes. A same-name remote branch is treated as a likely fork only when the refs share ancestry. The Actionable row is shown only when the local branch is behind that remote branch; a local branch that is only ahead of the other remote branch is omitted because it is rarely meant to push directly to that upstream. These fork rows use the other remote branch as their default `vs. ...` compare base and still show `↑x`/`↓y` counts against that remote branch. + +The `Actionable` header has a `Fetch` action. The panel also periodically fetches remotes every few minutes so local upstream status and same-name fork status stay fresh without requiring a manual refresh. + +Items are sorted by operational urgency, then recency. An unclean working tree is always first. Branch urgency is based on conflicts/diverged, behind, unpushed, dirty, and stale states. Branch and commit rows include succinct relative dates such as `5 minutes ago`, `yesterday`, `4 days ago`, and `last week`. + +## Working Tree + +The `Working tree` row expands to a compact changed-file list. There is no staged-versus-unstaged grouping in the panel UI. Each changed file has a selector, and newly appearing changed files are selected by default. + +The working-tree subsection header shows a tri-state checkbox before the selection summary. Checked means all files are selected, unchecked means none are selected, and partial means some files are selected; clicking a partial or unchecked checkbox selects all files, while clicking a checked checkbox unselects all files. The summary reads `x of y files selected` and shows selected-file `+x`/`-y` line stats immediately after the label when non-zero. + +The working-tree header actions are: + +- Commit selected files. +- Stash selected files. +- Discard selected files, with confirmation. + +Commit selected and stash selected generate their messages by default. Holding Shift while pressing either action opens the same optional-message dialog path used by the existing Git action control: the commit field is labeled `Commit message (optional)`, its placeholder is `Leave empty to auto-generate`, and a blank message still uses the generation flow. The stash dialog behaves the same way for stash messages. + +Branch sync actions such as push, pull, fetch, publish, and undo latest commit are intentionally not rendered on the `Working tree` row. They belong to branch rows, where the target branch is explicit. + +File rows are compact. They show a one-letter status indicator such as `A`, `D`, `M`, or `R`, line change counts, and hover/focus-only action buttons. `+x` uses the added-line color and `-y` uses the removed-line color. Zero counts are hidden. Expanding a file row opens an inline diff for that file change in working-tree, commit, stash, branch, and compare file lists. The row actions are: + +- Open file in the right-panel File surface. +- Open in VS Code through the preferred editor or host bridge. +- Discard changes, with confirmation. + +Untracked directories are expanded to file-level rows instead of being shown as a single folder row. Untracked files get `A` rows with line stats computed from a `/dev/null` comparison. The server also runs rename detection for unstaged untracked destinations through a temporary Git index, so staged and unstaged renames both collapse matching old/new paths into a single `R` row when Git can match them. If Git cannot match the similarity threshold, entries remain file-level `A` and `D` rows rather than a folder row. + +The `Working tree` context menu includes selected-file commit and stash actions plus a separated destructive `Discard selected changes` action. + +## Branch Rows + +Branch rows are compact tree items used both in `Actionable` and under `Remotes`. They show branch identity, sync indicators, head labels, and a relative activity date. + +Ahead and behind status is rendered as `↑x` and `↓y`; zero sides are hidden. `↑x` uses the same green as added-line indicators. `↓y` uses the warning/yellow download color. If a branch has both indicators it is diverged; no separate diverged badge is needed. + +Synced local branches shown under `Remotes` use a muted target icon before the branch label. Local-only or not-fully-synced branches use the same branch row model and expose the same expandable details wherever they appear. + +Branch action buttons appear only on row hover/focus and are absolutely positioned over the right side of the row. Supported branch actions include: + +- Switch to branch. +- Fetch, pull, push, publish, or smart sync, using state-specific icons. +- Delete branch. +- Undo latest commit when the current branch has commits not yet synced upstream. +- Merge branch into the current branch. +- Rebase current branch onto branch, using the `git-pull-request-arrow` icon. + +Smart sync handles diverged branches by prompting for one of three choices: force pull, normal merge sync, or force push. Modifier-key tooltips stay terse; for example pull can note `Shift: reset` and `Option: fetch`. + +## Branch Details + +Expanding a branch reveals branch details: + +- `vs. ...`, a non-expandable compare-base row. +- `X Ahead` +- `Y Behind` +- `History` +- `Changes` + +Every expanded branch shows the `vs. ...` row first. Its default base is the branch's upstream when available, otherwise the repository default comparison ref. Actionable same-name fork rows default this base to the other remote branch they are tracking for updates. Clicking the row opens a searchable ref picker so the user can choose another compare base. Compare rows do not show count prefixes or extra choose labels. Empty ahead and behind subsections are hidden. Ahead and behind labels include the count directly in the title and use the same colored upload/download icons as branch sync indicators. `History` is collapsed by default and loads commits in pages of 10. When more commits are available, a load-more row appends the next page inline until no more history remains. + +The branch-level `Changes` row summarizes the selected comparison as file count and line stats before expanding to the changed-file list. + +Branch-level `X Ahead` and `Y Behind` rows are only shown for branches that have an upstream. Local-only branches still support the `vs. ...` compare-base row, but they do not render upstream ahead/behind rows because there is no upstream relationship. + +Ahead, behind, history, and changes file lists use the shared compact file-change row model. + +## Commit Rows + +Commit rows appear in branch history and ahead/behind lists. They show an author avatar, commit message, branch/tag labels, line-change indicators, and a relative date. Author avatars come from commit author email when possible and fall back to compact initials when no avatar is available. The short SHA is available from the commit tooltip and context-menu copy action rather than the row label. + +Commit labels are de-duplicated: + +- A commit that is head of a local branch and its synced upstream shows the local branch label with a muted target icon. +- A commit that is head of a local branch but not synced upstream shows the local branch label. +- A commit that is only head of the upstream-tracking branch shows a muted target icon before the branch name. +- Tag labels use a tag icon before the tag name. + +Commit tooltips are structured panels with author name and avatar, relative and readable commit time, branch/tag labels, message, and line-change indicators. + +Commit rows expand to their changed files. Hover/focus-only commit actions include: + +- Revert commit. +- Rebase current branch onto commit. +- Checkout as detached HEAD. +- Create branch from commit. + +## Stashes + +Stashes are listed as `Actionable` tree rows. Each stash shows its message, ref, branch context when available, and relative date. Expanding a stash loads and shows the stash's changed files using the same compact file-change row model used by commits and compare results. + +Stash row actions appear on hover/focus and include: + +- Apply stash. +- Pop stash. +- Drop stash. + +Creating a stash is done from the dirty `Working tree` row through `Stash selected`. + +## Remotes + +`Remotes` remains a separate section because it is the most useful way to inspect remote activity at a glance. The section header exposes: + +- Fetch all remotes. +- Add remote, via a modal form. + +Each remote row shows the remote name and fetch URL. Remote action buttons appear on hover/focus and include fetch and remove. + +When local-only branches exist, `Remotes` also shows an `unpublished` tree row with those branches and an `x branch(es)` secondary label. Publishing one local-only branch sets its upstream. If the repository has multiple remotes, the panel prompts for the remote to publish to. + +Expanding a remote lists actual remote branches; pseudo-ref rows such as the remote name itself are de-duplicated. Remote branch rows use the same branch item model as `Actionable`, including local tracking state, `↑x`/`↓y` sync indicators, synced-local target icons, selectable compare bases, branch details, and branch actions. + +## Git Operations + +The panel routes all repository mutations through server-side RPC methods and refreshes status after operations. Implemented operation groups include: + +- Snapshot and detail loading: panel snapshot, same-name fork ancestry checks, branch details, branch commit pages, stash details, compare data, and file-change details. +- Working tree operations: selected-file commit, selected-file stash, discard selected files, discard individual changed files, read/open file data, stage/unstage helpers kept at the service boundary for compatibility. +- Branch operations: fetch branch, pull, push, publish, switch, delete, undo latest commit, merge branch into current branch, and rebase current branch onto another branch or commit. +- Commit operations: revert commit, checkout detached HEAD, and create branch from commit. +- Stash operations: apply, pop, and drop. +- Remote operations: list, add, remove, fetch one remote, fetch one branch ref, periodic Actionable fetch, and fetch all remotes. + +Non-current branch fetches are scoped to the selected branch. Operation busy state is keyed per action target so fetching one branch does not disable equivalent actions on other branches or remote entries. + +Selected-file commit, stash, and discard operations preserve rename source paths when needed, so selecting an `R` row sends both the destination path and the original path to the Git operation. Discard operations also split staged and unstaged portions explicitly. Server-side discard handling partitions tracked, untracked, HEAD-backed, and newly added paths before running Git restore/reset/clean commands, so mixed selections such as tracked edits plus untracked files do not cause one path class to prevent the rest of the selected discard from applying. + +## Error Handling + +Git action failures surface through the panel error state and existing toast/error paths. Panel errors are capped to a short scrollable block so large Git output cannot consume the full panel, and the error block has a floating copy action for debugging. Destructive actions such as discard selected changes, discard an individual file, delete branch, remove remote, drop stash, force pull, and force push require confirmation or an explicit dialog choice before execution. + +The panel keeps version-control actions server-authoritative across browser, desktop, VS Code, and remote clients. Client code does not directly execute Git commands. + +## Validation + +The current implementation has been exercised against the throwaway repository at `~/Sites/throwaway` with Playwright for the main panel flows: section resizing/collapse behavior, Actionable selection, selected-file commit and stash dialogs, branch sync indicators, remotes tree expansion, stash expansion, hover-only actions, failure reporting, and live filesystem updates including gitignored-file suppression. Rename coverage includes committing an unstaged `R` row and undoing that commit, verifying that the panel returns to a single `R` row rather than separate `A` and `D` rows. + +Before considering source-control changes complete, run: + +```sh +pnpm exec vp check +pnpm exec vp run typecheck +``` + +If native mobile code changes in a future pass, also run: + +```sh +pnpm exec vp run lint:mobile +``` diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 2ffda3dc50..14da98cfa0 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -114,11 +114,23 @@ export const layer = Layer.effect( ): Electron.MenuItemConstructorOptions[] => { const template: Electron.MenuItemConstructorOptions[] = []; let hasInsertedDestructiveSeparator = false; + let lastWasSeparator = false; for (const item of entries) { - if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) { - template.push({ type: "separator" }); + if (item.separator === true) { + if (template.length > 0 && !lastWasSeparator) { + template.push({ type: "separator" }); + lastWasSeparator = true; + } + continue; + } + + if (item.destructive && !hasInsertedDestructiveSeparator) { hasInsertedDestructiveSeparator = true; + if (template.length > 0 && !lastWasSeparator) { + template.push({ type: "separator" }); + lastWasSeparator = true; + } } const itemOption: Electron.MenuItemConstructorOptions = { @@ -138,6 +150,7 @@ export const layer = Layer.effect( } template.push(itemOption); + lastWasSeparator = false; } return template; diff --git a/apps/server/src/sourceControl/SourceControlPanelService.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.test.ts new file mode 100644 index 0000000000..8fceec21d4 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.test.ts @@ -0,0 +1,672 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { GitCommandError, type VcsRef, type VcsStatusLocalResult } from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { + GitVcsDriver, + type ExecuteGitInput, + type ExecuteGitResult, + type GitVcsDriverShape, +} from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; + +const success = (stdout = ""): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const failure = (stderr: string): ExecuteGitResult => ({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr, + stdoutTruncated: false, + stderrTruncated: false, +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: () => + Effect.fail( + new GitCommandError({ + operation: "test.status", + command: "git status", + cwd: "/repo", + detail: "status not stubbed", + }), + ), + localStatus: () => + Effect.fail( + new GitCommandError({ + operation: "test.localStatus", + command: "git status", + cwd: "/repo", + detail: "local status not stubbed", + }), + ), + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "test.pullCurrentBranch", + command: "git pull", + cwd: "/repo", + detail: "pull not stubbed", + }), + ), + ...workflow, + } as GitWorkflowServiceShape), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriverShape), + ), + ); +} + +const localStatus: VcsStatusLocalResult = { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/source-control", + hasWorkingTreeChanges: true, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; + +describe("SourceControlPanelService", () => { + it.effect("uses the selected branch head for history queries", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-list", "--count", "feature/source-control"], + [ + "log", + "--skip=0", + "--max-count=10", + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + "feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[0] === "rev-list" ? "0" : ""); + }), + ), + ), + ); + }); + + it.effect("cleans staged additions missing from HEAD without failing tracked paths", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.discardFiles({ + cwd: "/repo", + paths: ["new-file.ts"], + staged: true, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["ls-tree", "-r", "--name-only", "HEAD", "--", "new-file.ts"], + ["reset", "--", "new-file.ts"], + ["clean", "-fd", "--", "new-file.ts"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(""); + }), + ), + ), + ); + }); + + it.effect("discards mixed tracked and untracked unstaged files in one action", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.discardFiles({ + cwd: "/repo", + paths: ["tracked.ts", "new-file.ts"], + staged: false, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["ls-files", "--cached", "--", "tracked.ts", "new-file.ts"], + ["restore", "--worktree", "--", "tracked.ts"], + ["clean", "-fd", "--", "tracked.ts", "new-file.ts"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.discardUnstagedFiles.listIndexPaths" + ? success("tracked.ts\n") + : success(""); + }), + ), + ), + ); + }); + + it.effect("preserves multiline commit message formatting in one git argument", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + message: "Subject\nBody without blank separator", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["commit", "-m", "Subject\nBody without blank separator"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("sets upstream when force-pushing an unpublished branch", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pushBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "feature/source-control@{upstream}", + ], + [ + "push", + "--force-with-lease", + "-u", + "origin", + "feature/source-control:refs/heads/feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.upstreamForRef" + ? failure("no upstream") + : success(); + }), + ), + ), + ); + }); + + it.effect("keeps staged rename stats keyed by the destination path", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + const stagedFiles = + snapshot.changeGroups.find((group) => group.kind === "staged")?.files ?? []; + + assert.deepStrictEqual(stagedFiles, [ + { + path: "src/new.ts", + originalPath: "src/old.ts", + status: "renamed", + insertions: 3, + deletions: 1, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head feature/source-control", + "2 R. N... 100644 100644 100644 abc abc R100 src/new.ts\tsrc/old.ts", + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + return success("3\t1\t\0src/old.ts\0src/new.ts\0"); + case "vcs.panel.stagedNameStatus": + return success("R100\0src/old.ts\0src/new.ts\0"); + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ), + ); + + it.effect("enriches visible untracked files with stats and rename matches", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.enrichWorkingTreeFiles({ + cwd: "/repo", + paths: ["blast-review/SKILL.md", "blast-review/agents/openai.yaml"], + }); + + assert.deepStrictEqual(result, { + hiddenPaths: ["copilot-blast-review/agents/openai.yaml", "copilot-blast-review/SKILL.md"], + files: [ + { + path: "blast-review/agents/openai.yaml", + originalPath: "copilot-blast-review/agents/openai.yaml", + status: "renamed", + insertions: 6, + deletions: 1, + }, + { + path: "blast-review/SKILL.md", + originalPath: "copilot-blast-review/SKILL.md", + status: "renamed", + insertions: 2, + deletions: 1, + }, + ], + }); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.enrichWorkingTreeFiles.statusPorcelain": + assert.deepStrictEqual(input.args, [ + "status", + "--porcelain=2", + "--branch", + "-uall", + ]); + return success( + [ + "# branch.oid abc", + "# branch.head main", + "1 .D N... 100644 100644 000000 abc abc copilot-blast-review/SKILL.md", + "? blast-review/SKILL.md", + "? blast-review/agents/openai.yaml", + "? blast-review/scripts/blast-review.ts", + ].join("\n"), + ); + case "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat": + return success("0\t20\tcopilot-blast-review/SKILL.md\n"); + case "vcs.panel.enrichWorkingTreeFiles.untrackedNumstat": { + const path = input.args.at(-1); + if (path === "blast-review/SKILL.md") { + return success("21\t0\t\0/dev/null\0blast-review/SKILL.md\0"); + } + if (path === "blast-review/agents/openai.yaml") { + return success("6\t0\t\0/dev/null\0blast-review/agents/openai.yaml\0"); + } + return success(""); + } + case "vcs.panel.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.tempIndexReadTree": + case "vcs.panel.tempIndexIntentToAdd": + return success(""); + case "vcs.panel.unstagedNameStatusWithUntracked": + return success( + [ + "R043", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "R035", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + case "vcs.panel.unstagedNumstatWithUntracked": + return success( + [ + "2\t1\t", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "6\t1\t", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + default: + return success(""); + } + }), + ), + ), + ), + ); + + it.effect("uses all untracked destinations when enriching a visible deleted source", () => { + const calls: ExecuteGitInput[] = []; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.enrichWorkingTreeFiles({ + cwd: "/repo", + paths: ["copilot-blast-review/SKILL.md"], + }); + + assert.deepStrictEqual(result.files, [ + { + path: "blast-review/SKILL.md", + originalPath: "copilot-blast-review/SKILL.md", + status: "renamed", + insertions: 2, + deletions: 1, + }, + ]); + assert.deepStrictEqual(result.hiddenPaths, ["copilot-blast-review/SKILL.md"]); + assert.deepStrictEqual( + calls.find((call) => call.operation === "vcs.panel.tempIndexIntentToAdd")?.args, + [ + "add", + "-N", + "--", + "blast-review/SKILL.md", + "blast-review/agents/openai.yaml", + "blast-review/scripts/blast-review.ts", + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.enrichWorkingTreeFiles.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + "1 .D N... 100644 100644 000000 abc abc copilot-blast-review/SKILL.md", + "? blast-review/SKILL.md", + "? blast-review/agents/openai.yaml", + "? blast-review/scripts/blast-review.ts", + ].join("\n"), + ); + case "vcs.panel.enrichWorkingTreeFiles.unstagedNumstat": + return success("0\t20\tcopilot-blast-review/SKILL.md\n"); + case "vcs.panel.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.tempIndexReadTree": + case "vcs.panel.tempIndexIntentToAdd": + return success(""); + case "vcs.panel.unstagedNameStatusWithUntracked": + return success( + [ + "R043", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "R035", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + case "vcs.panel.unstagedNumstatWithUntracked": + return success( + [ + "2\t1\t", + "copilot-blast-review/SKILL.md", + "blast-review/SKILL.md", + "6\t1\t", + "copilot-blast-review/agents/openai.yaml", + "blast-review/agents/openai.yaml", + "", + ].join("\0"), + ); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("defers untracked detail loading from the initial snapshot", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + const unstagedFiles = + snapshot.changeGroups.find((group) => group.kind === "unstaged")?.files ?? []; + + assert.equal(unstagedFiles.length, 101); + assert.deepStrictEqual(unstagedFiles[0], { + path: "generated/file-000.txt", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + assert.notInclude( + [ + "vcs.panel.untrackedNumstat", + "vcs.panel.gitIndexPath", + "vcs.panel.tempIndexIntentToAdd", + "vcs.panel.unstagedNameStatusWithUntracked", + "vcs.panel.unstagedNumstatWithUntracked", + ], + input.operation, + ); + + switch (input.operation) { + case "vcs.panel.localBranches": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + ...Array.from( + { length: 101 }, + (_, index) => `? generated/file-${index.toString().padStart(3, "0")}.txt`, + ), + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ), + ); + + it.effect("surfaces same-name remote forks only when the local branch is behind", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.deepStrictEqual(snapshot.actionableForkBranches, [ + { + localBranchName: "feature", + remoteName: "upstream", + remoteBranchName: "feature", + remoteRefName: "upstream/feature", + aheadCount: 2, + behindCount: 3, + lastActivityAt: "2026-06-17T09:00:00.000Z", + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature\t*\t/repo\t2026-06-17T10:00:00.000Z\torigin/feature\t[ahead 1]", + ); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@example.test:fork/repo.git\t(fetch)", + "origin\tgit@example.test:fork/repo.git\t(push)", + "upstream\tgit@example.test:upstream/repo.git\t(fetch)", + "upstream\tgit@example.test:upstream/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return input.args.includes("origin/*") + ? success("origin/feature\t2026-06-17T08:00:00.000Z\n") + : success("upstream/feature\t2026-06-17T09:00:00.000Z\n"); + case "vcs.panel.branchForkMergeBase": + return success("abc123\n"); + case "vcs.panel.branchForkAheadBehind": + return success("2\t3\n"); + case "vcs.panel.statusPorcelain": + return success(["# branch.oid abc", "# branch.head feature"].join("\n")); + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + case "vcs.panel.stashes": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + it.effect("rejects option-like branch names before creating a branch", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .createBranchFromCommit({ + cwd: "/repo", + sha: "abc", + branchName: "-D", + }) + .pipe(Effect.flip); + + assert.equal(error.detail, 'Branch name cannot start with "-".'); + }).pipe( + Effect.provide( + makeTestLayer(() => + Effect.sync(() => { + throw new Error("git should not run for invalid branch names"); + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.ts b/apps/server/src/sourceControl/SourceControlPanelService.ts new file mode 100644 index 0000000000..ce62102280 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.ts @@ -0,0 +1,1997 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { createHash } from "node:crypto"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { + GitCommandError, + type VcsPanelAddRemoteInput, + type VcsPanelActionableForkBranch, + type VcsPanelBranchActionInput, + type VcsPanelBranchCommitsInput, + type VcsPanelBranchCommitsResult, + type VcsPanelBranchDetails, + type VcsPanelBranchDetailsInput, + type VcsPanelCommitActionInput, + type VcsPanelCommitInput, + type VcsPanelChangeGroup, + type VcsPanelCompareInput, + type VcsPanelCompareResult, + type VcsPanelDeleteBranchInput, + type VcsPanelFileActionInput, + type VcsPanelFileChange, + type VcsPanelFileDiffInput, + type VcsPanelFileDiffResult, + type VcsPanelFileStatus, + type VcsPanelRemote, + type VcsPanelRemoteInput, + type VcsPanelRefActionInput, + type VcsPanelSnapshotInput, + type VcsPanelSnapshotResult, + type VcsPanelStash, + type VcsPanelStashDetails, + type VcsPanelStashDetailsInput, + type VcsPanelStashInput, + type VcsPanelUndoCommitInput, + type VcsPanelWorkingTreeFileEnrichmentInput, + type VcsPanelWorkingTreeFileEnrichmentResult, + type VcsPullResult, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +const isGitCommandError = Schema.is(GitCommandError); + +export interface SourceControlPanelServiceShape { + readonly snapshot: ( + input: VcsPanelSnapshotInput, + ) => Effect.Effect; + readonly branchDetails: ( + input: VcsPanelBranchDetailsInput, + ) => Effect.Effect; + readonly branchCommits: ( + input: VcsPanelBranchCommitsInput, + ) => Effect.Effect; + readonly stashDetails: ( + input: VcsPanelStashDetailsInput, + ) => Effect.Effect; + readonly stageFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly unstageFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly discardFiles: (input: VcsPanelFileActionInput) => Effect.Effect; + readonly enrichWorkingTreeFiles: ( + input: VcsPanelWorkingTreeFileEnrichmentInput, + ) => Effect.Effect; + readonly readFileDiff: ( + input: VcsPanelFileDiffInput, + ) => Effect.Effect; + readonly commitStaged: (input: VcsPanelCommitInput) => Effect.Effect; + readonly pullBranch: ( + input: VcsPanelBranchActionInput, + ) => Effect.Effect; + readonly pushBranch: (input: VcsPanelBranchActionInput) => Effect.Effect; + readonly deleteBranch: (input: VcsPanelDeleteBranchInput) => Effect.Effect; + readonly undoLatestCommit: ( + input: VcsPanelUndoCommitInput, + ) => Effect.Effect; + readonly revertCommit: (input: VcsPanelCommitActionInput) => Effect.Effect; + readonly checkoutCommit: ( + input: VcsPanelCommitActionInput, + ) => Effect.Effect<{ readonly refName: string }, GitCommandError>; + readonly createBranchFromCommit: ( + input: VcsPanelCommitActionInput, + ) => Effect.Effect<{ readonly refName: string }, GitCommandError>; + readonly mergeBranchIntoCurrent: ( + input: VcsPanelRefActionInput, + ) => Effect.Effect; + readonly rebaseCurrentOnto: ( + input: VcsPanelRefActionInput, + ) => Effect.Effect; + readonly fetchBranch: (input: VcsPanelBranchActionInput) => Effect.Effect; + readonly fetchRemote: (input: VcsPanelRemoteInput) => Effect.Effect; + readonly fetchAllRemotes: (input: VcsPanelSnapshotInput) => Effect.Effect; + readonly addRemote: (input: VcsPanelAddRemoteInput) => Effect.Effect; + readonly removeRemote: (input: VcsPanelRemoteInput) => Effect.Effect; + readonly createStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly applyStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly popStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly dropStash: (input: VcsPanelStashInput) => Effect.Effect; + readonly compare: ( + input: VcsPanelCompareInput, + ) => Effect.Effect; +} + +export class SourceControlPanelService extends Context.Service< + SourceControlPanelService, + SourceControlPanelServiceShape +>()("t3/sourceControl/SourceControlPanelService") {} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function gitError(operation: string, cwd: string, args: readonly string[], detail: string) { + return new GitCommandError({ operation, command: commandLabel(args), cwd, detail }); +} + +function detailFromUnknown(cause: unknown): string { + if (cause instanceof Error && cause.message.length > 0) return cause.message; + if (typeof cause === "object" && cause !== null && "detail" in cause) { + const detail = cause.detail; + if (typeof detail === "string" && detail.length > 0) return detail; + } + return "Source control operation failed."; +} + +function asGitCommandError(operation: string, cwd: string, args: readonly string[]) { + return (cause: unknown) => + isGitCommandError(cause) ? cause : gitError(operation, cwd, args, detailFromUnknown(cause)); +} + +function parseCount(value: string | undefined): number { + const parsed = Number.parseInt(value ?? "0", 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function readNulField(output: string, startIndex: number) { + const endIndex = output.indexOf("\0", startIndex); + if (endIndex < 0) return { value: output.slice(startIndex), nextIndex: output.length }; + return { value: output.slice(startIndex, endIndex), nextIndex: endIndex + 1 }; +} + +function parseNumstat(output: string): Map { + const stats = new Map(); + if (output.includes("\0")) { + let index = 0; + while (index < output.length) { + const headerEndIndex = output.indexOf("\t", index); + if (headerEndIndex < 0) break; + const insertionsRaw = output.slice(index, headerEndIndex); + const deletionEndIndex = output.indexOf("\t", headerEndIndex + 1); + if (deletionEndIndex < 0) break; + const deletionsRaw = output.slice(headerEndIndex + 1, deletionEndIndex); + index = deletionEndIndex + 1; + let pathField = readNulField(output, index); + index = pathField.nextIndex; + if (pathField.value === "") { + pathField = readNulField(output, index); + index = pathField.nextIndex; + const renamedPathField = readNulField(output, index); + index = renamedPathField.nextIndex; + pathField = renamedPathField; + } + if (!pathField.value) continue; + stats.set(pathField.value, { + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return stats; + } + for (const line of output.split("\n")) { + const [insertionsRaw, deletionsRaw, path] = line.split("\t"); + if (!path) continue; + stats.set(path, { + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return stats; +} + +function mergeNumstats( + maps: Iterable>, +): Map { + const merged = new Map(); + for (const map of maps) { + for (const [path, stats] of map) { + const existing = merged.get(path); + merged.set(path, { + insertions: (existing?.insertions ?? 0) + stats.insertions, + deletions: (existing?.deletions ?? 0) + stats.deletions, + }); + } + } + return merged; +} + +function statusFromCode(code: string, fallback: VcsPanelFileStatus): VcsPanelFileStatus { + switch (code) { + case "A": + return "added"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "U": + return "conflicted"; + case "M": + return "modified"; + default: + return fallback; + } +} + +function addChange( + target: VcsPanelFileChange[], + input: { + path: string; + originalPath: string | null; + status: VcsPanelFileStatus; + stats?: { insertions: number; deletions: number } | undefined; + }, +) { + target.push({ + path: input.path, + originalPath: input.originalPath, + status: input.status, + insertions: input.stats?.insertions ?? 0, + deletions: input.stats?.deletions ?? 0, + }); +} + +function parsePorcelainStatus(input: { + status: string; + stagedFiles?: readonly VcsPanelFileChange[]; + stagedStats: Map; + unstagedStats: Map; + untrackedStats: Map; + unstagedFiles?: readonly VcsPanelFileChange[]; +}): VcsPanelChangeGroup[] { + const staged: VcsPanelFileChange[] = []; + const unstaged: VcsPanelFileChange[] = []; + const conflicts: VcsPanelFileChange[] = []; + + for (const line of input.status.split(/\r?\n/u)) { + if (line.length === 0 || line.startsWith("#")) continue; + if (line.startsWith("? ")) { + if (input.unstagedFiles !== undefined) continue; + const path = line.slice(2); + addChange(unstaged, { + path, + originalPath: null, + status: "untracked", + stats: input.untrackedStats.get(path), + }); + continue; + } + if (line.startsWith("u ")) { + const fields = line.split(" "); + const path = fields.slice(10).join(" "); + if (path.length > 0) { + addChange(conflicts, { + path, + originalPath: null, + status: "conflicted", + stats: input.unstagedStats.get(path) ?? input.stagedStats.get(path), + }); + } + continue; + } + + if (!line.startsWith("1 ") && !line.startsWith("2 ")) continue; + const xy = line.slice(2, 4); + const stagedCode = xy[0] ?? "."; + const unstagedCode = xy[1] ?? "."; + const isRename = line.startsWith("2 "); + const pathPart = isRename + ? line.split(" ").slice(9).join(" ") + : line.split(" ").slice(8).join(" "); + const [path = "", originalPath = null] = pathPart.split("\t"); + if (path.length === 0) continue; + if (stagedCode === "U" || unstagedCode === "U") { + addChange(conflicts, { + path, + originalPath, + status: "conflicted", + stats: input.unstagedStats.get(path) ?? input.stagedStats.get(path), + }); + continue; + } + if (stagedCode !== ".") { + if (input.stagedFiles !== undefined) continue; + addChange(staged, { + path, + originalPath, + status: statusFromCode(stagedCode, "modified"), + stats: input.stagedStats.get(path), + }); + } + if (unstagedCode !== ".") { + if (input.unstagedFiles !== undefined) continue; + addChange(unstaged, { + path, + originalPath, + status: statusFromCode(unstagedCode, "modified"), + stats: input.unstagedStats.get(path), + }); + } + } + + const sortFiles = (files: VcsPanelFileChange[]) => + files.toSorted((left, right) => left.path.localeCompare(right.path)); + return [ + { + kind: "staged" as const, + files: sortFiles(input.stagedFiles ? [...input.stagedFiles] : staged), + }, + { + kind: "unstaged" as const, + files: sortFiles(input.unstagedFiles ? [...input.unstagedFiles] : unstaged), + }, + { kind: "conflicts" as const, files: sortFiles(conflicts) }, + ]; +} + +function untrackedPathsFromPorcelain(status: string): string[] { + return status.split(/\r?\n/u).flatMap((line) => (line.startsWith("? ") ? [line.slice(2)] : [])); +} + +function unstagedFilesFromPorcelainStatus(input: { + status: string; + unstagedStats?: Map; + untrackedStats?: Map; +}): readonly VcsPanelFileChange[] { + return ( + parsePorcelainStatus({ + status: input.status, + stagedFiles: [], + stagedStats: new Map(), + unstagedStats: input.unstagedStats ?? new Map(), + untrackedStats: input.untrackedStats ?? new Map(), + }).find((group) => group.kind === "unstaged")?.files ?? [] + ); +} + +function parsePorcelainBranchSync(status: string) { + let hasUpstream = false; + let aheadCount = 0; + let behindCount = 0; + + for (const line of status.split(/\r?\n/u)) { + if (line.startsWith("# branch.upstream ")) { + hasUpstream = true; + continue; + } + if (line.startsWith("# branch.ab ")) { + for (const part of line.slice("# branch.ab ".length).split(" ")) { + if (part.startsWith("+")) { + const ahead = Number.parseInt(part.slice(1), 10); + if (Number.isFinite(ahead)) aheadCount = ahead; + } + if (part.startsWith("-")) { + const behind = Number.parseInt(part.slice(1), 10); + if (Number.isFinite(behind)) behindCount = behind; + } + } + } + } + + return { hasUpstream, aheadCount, behindCount }; +} + +function panelStatusFromLocal( + local: VcsStatusLocalResult, + porcelain: string, +): VcsPanelSnapshotResult["status"] { + const sync = parsePorcelainBranchSync(porcelain); + return { + ...local, + ...sync, + aheadOfDefaultCount: 0, + pr: null, + }; +} + +function parseRemoteVerbose(output: string): VcsPanelRemote[] { + const byName = new Map(); + for (const line of output.split("\n")) { + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/u.exec(line.trim()); + if (!match) continue; + const [, name, url, direction] = match; + if (!name || !url || !direction) continue; + const current = byName.get(name) ?? { fetchUrl: null, pushUrl: null }; + if (direction === "fetch") current.fetchUrl = url; + if (direction === "push") current.pushUrl = url; + byName.set(name, current); + } + return [...byName.entries()].map(([name, remote]) => ({ + name, + fetchUrl: remote.fetchUrl, + pushUrl: remote.pushUrl, + provider: remote.fetchUrl ? detectSourceControlProviderFromRemoteUrl(remote.fetchUrl) : null, + branches: [], + })); +} + +function parseRemoteBranches(output: string, remoteName: string): VcsPanelRemote["branches"] { + const seen = new Set(); + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const [name = "", lastActivityAt = ""] = line.split("\t"); + return { + name, + lastActivityAt: lastActivityAt.length > 0 ? lastActivityAt : null, + }; + }) + .filter((branch) => branch.name !== `${remoteName}/HEAD`) + .filter((branch) => branch.name !== remoteName) + .filter((branch) => { + const name = branch.name; + if (seen.has(name)) return false; + seen.add(name); + return true; + }) + .map((branch) => ({ + name: branch.name.startsWith(`${remoteName}/`) + ? branch.name.slice(remoteName.length + 1) + : branch.name, + fullRefName: branch.name, + isDefaultRemoteHead: false, + lastActivityAt: branch.lastActivityAt, + })) + .toSorted(compareBranchActivity); +} + +function parseStashes(output: string): VcsPanelStash[] { + return output.split("\n").flatMap((line) => { + const [refName, sha, createdAt, message] = line.split("\t"); + if (!refName) return []; + return [ + { + refName, + sha: sha && sha.length > 0 ? sha : null, + createdAt: createdAt && createdAt.length > 0 ? createdAt : null, + message: message && message.trim().length > 0 ? message.trim() : refName, + }, + ]; + }); +} + +function parseBranchTrackCounts(track: string): { + readonly aheadCount: number; + readonly behindCount: number; +} { + const aheadCount = Number.parseInt(/ahead (\d+)/u.exec(track)?.[1] ?? "0", 10); + const behindCount = Number.parseInt(/behind (\d+)/u.exec(track)?.[1] ?? "0", 10); + return { + aheadCount: Number.isFinite(aheadCount) ? aheadCount : 0, + behindCount: Number.isFinite(behindCount) ? behindCount : 0, + }; +} + +function parseAheadBehindCounts(output: string): { + readonly aheadCount: number; + readonly behindCount: number; +} { + const [aheadRaw = "0", behindRaw = "0"] = output.trim().split(/\s+/u); + const aheadCount = Number.parseInt(aheadRaw, 10); + const behindCount = Number.parseInt(behindRaw, 10); + return { + aheadCount: Number.isFinite(aheadCount) && aheadCount > 0 ? aheadCount : 0, + behindCount: Number.isFinite(behindCount) && behindCount > 0 ? behindCount : 0, + }; +} + +function parseLocalBranches(output: string): VcsRef[] { + const rows = output + .split(/\r?\n/u) + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .map((line) => { + const [ + name = "", + head = "", + worktreePath = "", + lastActivityAt = "", + upstreamName = "", + track = "", + ] = line.split("\t"); + const { aheadCount, behindCount } = parseBranchTrackCounts(track); + return { + name, + current: head.trim() === "*", + worktreePath: worktreePath.length > 0 ? worktreePath : null, + lastActivityAt: lastActivityAt.length > 0 ? lastActivityAt : null, + upstreamName: upstreamName.length > 0 ? upstreamName : null, + aheadCount, + behindCount, + }; + }) + .filter((branch) => branch.name.length > 0); + const defaultName = + rows.find((branch) => branch.name === "main")?.name ?? + rows.find((branch) => branch.name === "master")?.name ?? + rows.find((branch) => !branch.current)?.name ?? + rows[0]?.name ?? + null; + + return rows + .map((branch) => ({ + name: branch.name, + current: branch.current, + isDefault: branch.name === defaultName, + worktreePath: branch.worktreePath, + lastActivityAt: branch.lastActivityAt, + upstreamName: branch.upstreamName, + aheadCount: branch.aheadCount, + behindCount: branch.behindCount, + })) + .toSorted(compareBranchActivity); +} + +function branchActivityTime(value: { + readonly lastActivityAt?: string | null | undefined; +}): number { + if (!value.lastActivityAt) return 0; + const time = Date.parse(value.lastActivityAt); + return Number.isFinite(time) ? time : 0; +} + +function compareBranchActivity( + left: { readonly lastActivityAt?: string | null; readonly name: string }, + right: { readonly lastActivityAt?: string | null; readonly name: string }, +): number { + const activity = branchActivityTime(right) - branchActivityTime(left); + return activity !== 0 ? activity : left.name.localeCompare(right.name); +} + +function avatarUrlForEmail(email: string | null | undefined): string | null { + const normalized = email?.trim().toLowerCase(); + if (!normalized || !normalized.includes("@")) return null; + const hash = createHash("md5").update(normalized).digest("hex"); + return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=64`; +} + +function parseRefLines(output: string): string[] { + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.endsWith("/HEAD") && !line.includes(" -> ")) + .toSorted((left, right) => left.localeCompare(right)); +} + +function parsePathLines(output: string): string[] { + return output.split(/\r?\n/u).filter((line) => line.length > 0); +} + +function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +function parseCreatedFromRef(output: string): string | null { + for (const line of output.split(/\r?\n/u)) { + const match = /^branch: Created from (.+)$/u.exec(line.trim()); + const refName = match?.[1]?.trim(); + if (!refName || refName === "HEAD") continue; + return refName.replace(/^refs\/heads\//u, "").replace(/^refs\/remotes\//u, ""); + } + return null; +} + +function parseCommits(output: string): VcsPanelSnapshotResult["recentCommits"] { + return output.split("\n").flatMap((line) => { + const [sha, shortSha, authorName, authorEmail, authoredAt, message] = line.split("\t"); + if (!sha || !shortSha || !message) return []; + return [ + { + sha, + shortSha, + message, + authorName: authorName ?? null, + authorEmail: authorEmail ?? null, + authorAvatarUrl: avatarUrlForEmail(authorEmail), + authoredAt: authoredAt ?? null, + headRefs: [], + tags: [], + files: [], + }, + ]; + }); +} + +function fileStatusFromNameStatus(status: string | undefined): VcsPanelFileStatus { + if (!status) return "modified"; + if (status.startsWith("R")) return "renamed"; + if (status.startsWith("C")) return "copied"; + return statusFromCode(status[0] ?? "M", "modified"); +} + +function parseNameStatus( + output: string, +): Map { + const statuses = new Map(); + if (output.includes("\0")) { + const fields = output.split("\0").filter((field) => field.length > 0); + for (let index = 0; index < fields.length; index += 1) { + const statusRaw = fields[index]; + const firstPath = fields[index + 1]; + if (!statusRaw || !firstPath) continue; + const status = fileStatusFromNameStatus(statusRaw); + const hasSecondPath = statusRaw.startsWith("R") || statusRaw.startsWith("C"); + const secondPath = hasSecondPath ? fields[index + 2] : undefined; + if (hasSecondPath) index += 2; + else index += 1; + const path = secondPath ?? firstPath; + statuses.set(path, { + status, + originalPath: secondPath ? firstPath : null, + }); + } + return statuses; + } + for (const line of output.split("\n")) { + const [statusRaw, firstPath, secondPath] = line.split("\t"); + if (!statusRaw || !firstPath) continue; + const path = secondPath ?? firstPath; + statuses.set(path, { + status: fileStatusFromNameStatus(statusRaw), + originalPath: secondPath ? firstPath : null, + }); + } + return statuses; +} + +function parseFileChangesFromNumstat(input: { + numstat: string; + statuses?: Map; +}): VcsPanelFileChange[] { + const files: VcsPanelFileChange[] = []; + if (input.numstat.includes("\0")) { + let index = 0; + while (index < input.numstat.length) { + const headerEndIndex = input.numstat.indexOf("\t", index); + if (headerEndIndex < 0) break; + const insertionsRaw = input.numstat.slice(index, headerEndIndex); + const deletionEndIndex = input.numstat.indexOf("\t", headerEndIndex + 1); + if (deletionEndIndex < 0) break; + const deletionsRaw = input.numstat.slice(headerEndIndex + 1, deletionEndIndex); + index = deletionEndIndex + 1; + let pathField = readNulField(input.numstat, index); + index = pathField.nextIndex; + let originalPath: string | null = null; + if (pathField.value === "") { + const originalPathField = readNulField(input.numstat, index); + index = originalPathField.nextIndex; + const renamedPathField = readNulField(input.numstat, index); + index = renamedPathField.nextIndex; + originalPath = originalPathField.value || null; + pathField = renamedPathField; + } + const path = pathField.value; + if (!path) continue; + const status = input.statuses?.get(path); + files.push({ + path, + originalPath: status?.originalPath ?? originalPath, + status: status?.status ?? "modified", + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return files.toSorted((left, right) => left.path.localeCompare(right.path)); + } + for (const line of input.numstat.split("\n")) { + const [insertionsRaw, deletionsRaw, pathRaw, renamedPathRaw] = line.split("\t"); + const path = renamedPathRaw ?? pathRaw; + if (!path) continue; + const status = input.statuses?.get(path); + files.push({ + path, + originalPath: status?.originalPath ?? null, + status: status?.status ?? "modified", + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return files.toSorted((left, right) => left.path.localeCompare(right.path)); +} + +function validateGitPositionalName(input: { + readonly operation: string; + readonly cwd: string; + readonly args: readonly string[]; + readonly kind: string; + readonly value: string; +}): Effect.Effect { + const value = input.value.trim(); + if (value.length === 0) { + return Effect.fail( + gitError(input.operation, input.cwd, input.args, `${input.kind} is required.`), + ); + } + if (value.startsWith("-")) { + return Effect.fail( + gitError(input.operation, input.cwd, input.args, `${input.kind} cannot start with "-".`), + ); + } + return Effect.succeed(value); +} + +function targetRef(target: VcsPanelCompareInput["left"]): string { + switch (target.kind) { + case "working-tree": + return ""; + case "branch": + return target.refName; + case "stash": + return target.refName; + } +} + +export const make = Effect.fn("makeSourceControlPanelService")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver; + const path = yield* Path.Path; + const workflow = yield* GitWorkflowService; + const serverSettings = yield* ServerSettingsService; + const context = yield* Effect.context(); + const textGeneration = Option.getOrUndefined(Context.getOption(context, TextGeneration)); + + const run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { readonly allowNonZeroExit?: boolean; readonly env?: NodeJS.ProcessEnv }, + ) => + git + .execute({ + operation, + cwd, + args, + ...(options?.env !== undefined ? { env: options.env } : {}), + allowNonZeroExit: options?.allowNonZeroExit ?? false, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + appendTruncationMarker: true, + }) + .pipe( + Effect.flatMap((result) => { + if (options?.allowNonZeroExit === true || result.exitCode === 0) { + return Effect.succeed(result.stdout); + } + return Effect.fail( + gitError(operation, cwd, args, result.stderr.trim() || result.stdout.trim()), + ); + }), + Effect.mapError(asGitCommandError(operation, cwd, args)), + ); + + const COMMIT_PAGE_SIZE = 10; + + const commitFiles = (cwd: string, sha: string) => + Effect.all( + [ + run("vcs.panel.commitNumstat", cwd, [ + "show", + "--format=", + "--numstat", + "-z", + "--find-renames", + sha, + ]), + run("vcs.panel.commitNameStatus", cwd, [ + "show", + "--format=", + "--name-status", + "-z", + "--find-renames", + sha, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + Effect.orElseSucceed(() => []), + ); + + const commitRefsBySha = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => { + const commitShas = new Set(commits.map((commit) => commit.sha)); + if (commitShas.size === 0) { + return Effect.succeed( + new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(), + ); + } + + return run("vcs.panel.commitRefs", cwd, [ + "for-each-ref", + "--format=%(objectname)%09%(*objectname)%09%(refname:short)%09%(refname)", + "refs/heads", + "refs/remotes", + "refs/tags", + ]).pipe( + Effect.map((output) => { + const refs = new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(); + for (const line of output.split(/\r?\n/u)) { + const [objectName, peeledObjectName, shortRefName, fullRefName] = line.split("\t"); + const sha = peeledObjectName || objectName; + if (!sha || !shortRefName || !fullRefName || !commitShas.has(sha)) continue; + if (shortRefName.endsWith("/HEAD") || shortRefName.includes(" -> ")) continue; + if (fullRefName.startsWith("refs/remotes/") && !shortRefName.includes("/")) continue; + + const current = refs.get(sha) ?? { headRefs: [], tags: [] }; + if (fullRefName.startsWith("refs/tags/")) { + refs.set(sha, { + headRefs: current.headRefs, + tags: [...current.tags, shortRefName].toSorted((left, right) => + left.localeCompare(right), + ), + }); + continue; + } + refs.set(sha, { + headRefs: [...current.headRefs, shortRefName].toSorted((left, right) => + left.localeCompare(right), + ), + tags: current.tags, + }); + } + return refs; + }), + Effect.orElseSucceed( + () => + new Map< + string, + { readonly headRefs: readonly string[]; readonly tags: readonly string[] } + >(), + ), + ); + }; + + const withCommitDetails = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => + commitRefsBySha(cwd, commits).pipe( + Effect.flatMap((refsBySha) => + Effect.forEach( + commits, + (commit) => + commitFiles(cwd, commit.sha).pipe( + Effect.map((files) => ({ + ...commit, + ...(refsBySha.get(commit.sha) ?? { headRefs: [], tags: [] }), + files, + })), + ), + { concurrency: 2 }, + ), + ), + ); + + const parseCount = (value: string) => { + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + }; + + const countCommitsForRange = (cwd: string, range: string) => + run("vcs.panel.branchCommitCount", cwd, ["rev-list", "--count", range]).pipe( + Effect.map(parseCount), + Effect.orElseSucceed(() => 0), + ); + + const countAheadBehindForRefs = (cwd: string, leftRef: string, rightRef: string) => + run("vcs.panel.branchForkAheadBehind", cwd, [ + "rev-list", + "--left-right", + "--count", + `${leftRef}...${rightRef}`, + ]).pipe( + Effect.map(parseAheadBehindCounts), + Effect.orElseSucceed(() => ({ aheadCount: 0, behindCount: 0 })), + ); + + const refsShareAncestry = (cwd: string, leftRef: string, rightRef: string) => + run("vcs.panel.branchForkMergeBase", cwd, ["merge-base", leftRef, rightRef], { + allowNonZeroExit: true, + }).pipe( + Effect.map((output) => output.trim().length > 0), + Effect.orElseSucceed(() => false), + ); + + const actionableForkBranches = ( + cwd: string, + localBranches: readonly VcsRef[], + remotes: readonly VcsPanelRemote[], + ): Effect.Effect => { + if (remotes.length < 2) return Effect.succeed([]); + const candidates = localBranches.flatMap((localBranch) => + remotes.flatMap((remote) => + remote.branches + .filter((remoteBranch) => remoteBranch.name === localBranch.name) + .filter((remoteBranch) => localBranch.upstreamName !== remoteBranch.fullRefName) + .map((remoteBranch) => ({ localBranch, remote, remoteBranch })), + ), + ); + return Effect.forEach( + candidates, + ({ localBranch, remote, remoteBranch }) => + Effect.gen(function* () { + const shareAncestry = yield* refsShareAncestry( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (!shareAncestry) return null; + const counts = yield* countAheadBehindForRefs( + cwd, + localBranch.name, + remoteBranch.fullRefName, + ); + if (counts.behindCount <= 0) return null; + const fork = { + localBranchName: localBranch.name, + remoteName: remote.name, + remoteBranchName: remoteBranch.name, + remoteRefName: remoteBranch.fullRefName, + aheadCount: counts.aheadCount, + behindCount: counts.behindCount, + }; + return { + ...fork, + ...(remoteBranch.lastActivityAt ? { lastActivityAt: remoteBranch.lastActivityAt } : {}), + } satisfies VcsPanelActionableForkBranch; + }), + { concurrency: 4 }, + ).pipe( + Effect.map((forks) => + forks + .flatMap((fork) => (fork ? [fork] : [])) + .toSorted((left, right) => { + const activity = branchActivityTime(right) - branchActivityTime(left); + return activity !== 0 + ? activity + : `${left.remoteName}/${left.remoteBranchName}`.localeCompare( + `${right.remoteName}/${right.remoteBranchName}`, + ); + }), + ), + Effect.orElseSucceed(() => []), + ); + }; + + const commitShasForRange = (cwd: string, range: string) => + run("vcs.panel.branchCommitShas", cwd, ["rev-list", range]).pipe( + Effect.map((output) => + output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean), + ), + Effect.orElseSucceed(() => []), + ); + + const commitsForRange = ( + cwd: string, + range: string, + maxCount: number, + skip = 0, + ): Effect.Effect => + run("vcs.panel.branchCommits", cwd, [ + "log", + `--skip=${skip}`, + `--max-count=${maxCount}`, + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + range, + ]).pipe( + Effect.map(parseCommits), + Effect.flatMap((commits) => withCommitDetails(cwd, commits)), + ); + + const branchCommits = ( + cwd: string, + branch: VcsRef, + baseRef: string | null | undefined, + kind: VcsPanelBranchCommitsInput["kind"], + skip: number, + limit: number, + ): Effect.Effect => + Effect.gen(function* () { + const refName = branch.name; + const historyRef = yield* branchCommitRange(baseRef ?? null, refName, kind ?? "history"); + if (!historyRef) { + return { + commits: [], + remaining: 0, + }; + } + const [total, commits] = yield* Effect.all( + [countCommitsForRange(cwd, historyRef), commitsForRange(cwd, historyRef, limit, skip)], + { concurrency: "unbounded" }, + ); + return { + commits, + remaining: Math.max(0, total - skip - commits.length), + }; + }); + + const stashDetails = ( + cwd: string, + stashRef: string, + ): Effect.Effect => + Effect.all( + [ + run("vcs.panel.stashNumstat", cwd, [ + "stash", + "show", + "--numstat", + "-z", + "--find-renames", + "--include-untracked", + stashRef, + ]), + run("vcs.panel.stashNameStatus", cwd, [ + "stash", + "show", + "--name-status", + "-z", + "--find-renames", + "--include-untracked", + stashRef, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + Effect.orElseSucceed(() => []), + Effect.map((files) => ({ + refName: stashRef, + files, + })), + ); + + const generatedStashMessage = ( + cwd: string, + mode: "all" | "staged" | "unstaged", + paths?: readonly string[], + ): Effect.Effect => + Effect.gen(function* () { + const fallback = `T3 Code ${mode} stash`; + const diffArgs = + mode === "staged" + ? (["diff", "--cached", "--stat"] as const) + : (["diff", "--stat"] as const); + const patchArgs = + mode === "staged" + ? (["diff", "--cached", "--no-ext-diff", "--patch", "--minimal"] as const) + : (["diff", "--no-ext-diff", "--patch", "--minimal"] as const); + const pathArgs = paths && paths.length > 0 ? (["--", ...paths] as const) : []; + const [settings, summary, patch, status] = yield* Effect.all( + [ + serverSettings.getSettings, + run("vcs.panel.stashMessageSummary", cwd, [...diffArgs, ...pathArgs]), + run("vcs.panel.stashMessagePatch", cwd, [...patchArgs, ...pathArgs]), + run("vcs.panel.stashMessageStatus", cwd, ["status", "--short"]), + ], + { concurrency: "unbounded" }, + ); + const stagedSummary = [summary.trim(), status.trim()].filter(Boolean).join("\n"); + if (!textGeneration) return fallback; + if (stagedSummary.length === 0 && patch.trim().length === 0) return fallback; + const generated = yield* textGeneration.generateCommitMessage({ + cwd, + branch: null, + stagedSummary: stagedSummary.slice(0, 8_000), + stagedPatch: patch.slice(0, 50_000), + modelSelection: settings.textGenerationModelSelection, + }); + return generated.subject.trim() || fallback; + }).pipe(Effect.orElseSucceed(() => `T3 Code ${mode} stash`)); + + const compareFiles = (cwd: string, baseRef: string | null, refName: string) => { + if (!baseRef) return Effect.succeed([]); + return Effect.all( + [ + run("vcs.panel.branchCompareNumstat", cwd, [ + "diff", + "--numstat", + "-z", + "--find-renames", + `${baseRef}...${refName}`, + ]), + run("vcs.panel.branchCompareNameStatus", cwd, [ + "diff", + "--name-status", + "-z", + "--find-renames", + `${baseRef}...${refName}`, + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([numstat, nameStatus]) => + parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }), + ), + Effect.orElseSucceed(() => []), + ); + }; + + const branchCommitRange = ( + baseRef: string | null, + refName: string, + kind: NonNullable, + ) => { + switch (kind) { + case "ahead": + return Effect.succeed(baseRef ? `${baseRef}..${refName}` : ""); + case "behind": + return Effect.succeed(baseRef ? `${refName}..${baseRef}` : ""); + case "compare-history": + case "history": + return Effect.succeed(refName); + } + }; + + const upstreamForRef = (cwd: string, refName: string) => + run("vcs.panel.branchUpstream", cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + `${refName}@{upstream}`, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + Effect.map((value) => (value.length > 0 ? value : null)), + ); + + const createdFromRef = (cwd: string, refName: string) => + run("vcs.panel.branchCreatedFrom", cwd, [ + "reflog", + "show", + "--format=%gs", + "--max-count=20", + refName, + ]).pipe( + Effect.map(parseCreatedFromRef), + Effect.orElseSucceed(() => null), + ); + + const branchDetails = ( + cwd: string, + branch: VcsRef, + defaultCompareRef: string | null, + compareBaseRef?: string, + ): Effect.Effect => + Effect.gen(function* () { + const refName = branch.name; + const upstreamRef = branch.isRemote ? null : yield* upstreamForRef(cwd, refName); + const createdBaseRef = upstreamRef ? null : yield* createdFromRef(cwd, refName); + const baseRef = + compareBaseRef ?? + upstreamRef ?? + createdBaseRef ?? + (!branch.isDefault ? defaultCompareRef : null); + const unsyncedBaseRef = branch.isRemote ? null : (upstreamRef ?? defaultCompareRef); + const historyRef = refName; + const [ + aheadCommits, + aheadCommitTotal, + behindCommits, + behindCommitTotal, + totalCommits, + commits, + files, + unsyncedCommitShas, + ] = yield* Effect.all( + [ + baseRef + ? commitsForRange(cwd, `${baseRef}..${refName}`, COMMIT_PAGE_SIZE) + : Effect.succeed([]), + baseRef ? countCommitsForRange(cwd, `${baseRef}..${refName}`) : Effect.succeed(0), + baseRef + ? commitsForRange(cwd, `${refName}..${baseRef}`, COMMIT_PAGE_SIZE) + : Effect.succeed([]), + baseRef ? countCommitsForRange(cwd, `${refName}..${baseRef}`) : Effect.succeed(0), + countCommitsForRange(cwd, historyRef), + commitsForRange(cwd, historyRef, COMMIT_PAGE_SIZE), + compareFiles(cwd, baseRef, refName), + unsyncedBaseRef + ? commitShasForRange(cwd, `${unsyncedBaseRef}..${refName}`) + : Effect.succeed([]), + ], + { concurrency: "unbounded" }, + ); + return { + name: branch.name, + fullRefName: branch.name, + isRemote: branch.isRemote === true, + remoteName: branch.remoteName ?? null, + current: branch.current, + isDefault: branch.isDefault, + worktreePath: branch.worktreePath, + upstreamRef, + baseRef, + unsyncedCommitShas, + aheadCommits, + aheadCommitsRemaining: Math.max(0, aheadCommitTotal - aheadCommits.length), + behindCommits, + behindCommitsRemaining: Math.max(0, behindCommitTotal - behindCommits.length), + compareCommits: [], + compareCommitsRemaining: 0, + commits, + commitsRemaining: Math.max(0, totalCommits - commits.length), + compareFiles: files, + }; + }); + + const unstagedFilesWithUntrackedRenames = (cwd: string, untrackedPaths: readonly string[]) => + Effect.gen(function* () { + if (untrackedPaths.length === 0) return null; + + const gitIndexPath = (yield* run("vcs.panel.gitIndexPath", cwd, [ + "rev-parse", + "--git-path", + "index", + ])).trim(); + if (!gitIndexPath) return null; + + const sourceIndexPath = path.isAbsolute(gitIndexPath) + ? gitIndexPath + : path.resolve(cwd, gitIndexPath); + const tempDir = yield* fileSystem.makeTempDirectory({ prefix: "t3-vcs-index-" }); + return yield* Effect.gen(function* () { + const tempIndexPath = path.join(tempDir, "index"); + const env = { ...globalThis.process.env, GIT_INDEX_FILE: tempIndexPath }; + yield* fileSystem.copyFile(sourceIndexPath, tempIndexPath).pipe( + Effect.catch(() => + run("vcs.panel.tempIndexReadTree", cwd, ["read-tree", "HEAD"], { env }).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ), + ), + ); + yield* run("vcs.panel.tempIndexIntentToAdd", cwd, ["add", "-N", "--", ...untrackedPaths], { + env, + }).pipe(Effect.asVoid); + const [nameStatus, numstat] = yield* Effect.all( + [ + run( + "vcs.panel.unstagedNameStatusWithUntracked", + cwd, + ["diff", "--name-status", "-z", "--find-renames=20%"], + { env }, + ), + run( + "vcs.panel.unstagedNumstatWithUntracked", + cwd, + ["diff", "--numstat", "-z", "--find-renames=20%"], + { env }, + ), + ], + { concurrency: "unbounded" }, + ); + return parseFileChangesFromNumstat({ + numstat, + statuses: parseNameStatus(nameStatus), + }); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }).pipe(Effect.orElseSucceed(() => null)); + + const enrichWorkingTreeFiles: SourceControlPanelServiceShape["enrichWorkingTreeFiles"] = + Effect.fn("enrichWorkingTreeFiles")(function* (input) { + const requestedPaths = uniquePaths(input.paths); + const [porcelain, unstagedNumstat] = yield* Effect.all( + [ + run("vcs.panel.enrichWorkingTreeFiles.statusPorcelain", input.cwd, [ + "status", + "--porcelain=2", + "--branch", + "-uall", + ]), + run("vcs.panel.enrichWorkingTreeFiles.unstagedNumstat", input.cwd, [ + "diff", + "--numstat", + "-z", + "--find-renames=20%", + ]), + ], + { concurrency: "unbounded" }, + ); + + const requestedPathSet = new Set(requestedPaths); + const untrackedPaths = untrackedPathsFromPorcelain(porcelain); + const untrackedPathSet = new Set(untrackedPaths); + const unstagedFiles = unstagedFilesFromPorcelainStatus({ + status: porcelain, + unstagedStats: parseNumstat(unstagedNumstat), + }); + const deletedPathSet = new Set( + unstagedFiles.filter((file) => file.status === "deleted").map((file) => file.path), + ); + const requestedUntrackedPaths = requestedPaths.filter((path) => untrackedPathSet.has(path)); + const requestedDeletedPaths = requestedPaths.filter((path) => deletedPathSet.has(path)); + const renameCandidateUntrackedPaths = + requestedDeletedPaths.length > 0 ? untrackedPaths : requestedUntrackedPaths; + + const [untrackedStats, renameCandidates] = yield* Effect.all( + [ + Effect.forEach( + requestedUntrackedPaths, + (path) => + run( + "vcs.panel.enrichWorkingTreeFiles.untrackedNumstat", + input.cwd, + ["diff", "--no-index", "--numstat", "-z", "--", "/dev/null", path], + { allowNonZeroExit: true }, + ).pipe( + Effect.map(parseNumstat), + Effect.orElseSucceed(() => new Map()), + ), + { concurrency: 4 }, + ).pipe(Effect.map((stats) => mergeNumstats(stats))), + unstagedFilesWithUntrackedRenames(input.cwd, renameCandidateUntrackedPaths), + ], + { concurrency: "unbounded" }, + ); + + const filesByPath = new Map(); + const hiddenPaths = new Set(); + for (const file of renameCandidates ?? []) { + if (file.status !== "renamed" || !file.originalPath) continue; + if (!requestedPathSet.has(file.path) && !requestedPathSet.has(file.originalPath)) continue; + filesByPath.set(file.path, file); + hiddenPaths.add(file.originalPath); + } + + for (const path of requestedUntrackedPaths) { + if (filesByPath.has(path)) continue; + const stats = untrackedStats.get(path); + filesByPath.set(path, { + path, + originalPath: null, + status: "untracked", + insertions: stats?.insertions ?? 0, + deletions: stats?.deletions ?? 0, + }); + } + for (const file of unstagedFiles) { + if (file.status !== "deleted") continue; + if ( + !requestedPathSet.has(file.path) || + hiddenPaths.has(file.path) || + filesByPath.has(file.path) + ) { + continue; + } + filesByPath.set(file.path, file); + } + + return { + files: [...filesByPath.values()].toSorted((left, right) => + left.path.localeCompare(right.path), + ), + hiddenPaths: [...hiddenPaths].toSorted((left, right) => left.localeCompare(right)), + }; + }); + + const snapshot: SourceControlPanelServiceShape["snapshot"] = Effect.fn("snapshot")( + function* (input) { + const [ + localStatus, + localBranchesOutput, + porcelain, + unstagedNumstat, + stagedNumstat, + stagedNameStatus, + remotesOutput, + stashes, + ] = yield* Effect.all( + [ + workflow + .localStatus(input) + .pipe( + Effect.mapError(asGitCommandError("vcs.panel.localStatus", input.cwd, ["status"])), + ), + run("vcs.panel.localBranches", input.cwd, [ + "branch", + "--format=%(refname:short)%09%(HEAD)%09%(worktreepath)%09%(committerdate:iso-strict)%09%(upstream:short)%09%(upstream:track)", + ]), + run("vcs.panel.statusPorcelain", input.cwd, [ + "status", + "--porcelain=2", + "--branch", + "-uall", + ]), + run("vcs.panel.unstagedNumstat", input.cwd, [ + "diff", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.stagedNumstat", input.cwd, [ + "diff", + "--cached", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.stagedNameStatus", input.cwd, [ + "diff", + "--cached", + "--name-status", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.remotes", input.cwd, ["remote", "-v"]), + run("vcs.panel.stashes", input.cwd, [ + "stash", + "list", + "--format=%gd%x09%H%x09%cI%x09%gs", + ]), + ], + { concurrency: "unbounded" }, + ); + + const localBranches = parseLocalBranches(localBranchesOutput); + const stagedFiles = parseFileChangesFromNumstat({ + numstat: stagedNumstat, + statuses: parseNameStatus(stagedNameStatus), + }); + const remotes = parseRemoteVerbose(remotesOutput); + const remotesWithBranches = yield* Effect.forEach( + remotes, + (remote) => + run("vcs.panel.remoteBranches", input.cwd, [ + "branch", + "-r", + "--list", + `${remote.name}/*`, + "--format=%(refname:short)%09%(committerdate:iso-strict)", + ]).pipe( + Effect.map((branchesOutput) => ({ + ...remote, + branches: parseRemoteBranches(branchesOutput, remote.name), + })), + Effect.orElseSucceed(() => remote), + ), + { concurrency: "unbounded" }, + ); + const defaultCompareRef = + localBranches.find((ref) => ref.isDefault && !ref.current)?.name ?? + localBranches.find((ref) => !ref.current)?.name ?? + null; + const forkBranches = yield* actionableForkBranches( + input.cwd, + localBranches, + remotesWithBranches, + ); + return { + status: panelStatusFromLocal(localStatus, porcelain), + changeGroups: parsePorcelainStatus({ + status: porcelain, + stagedFiles, + stagedStats: parseNumstat(stagedNumstat), + unstagedStats: parseNumstat(unstagedNumstat), + untrackedStats: new Map(), + }), + localBranches, + branchDetails: [], + remotes: remotesWithBranches, + actionableForkBranches: forkBranches, + stashes: parseStashes(stashes), + recentCommits: [], + defaultCompareRef, + }; + }, + ); + + const stageFiles: SourceControlPanelServiceShape["stageFiles"] = (input) => + run("vcs.panel.stageFiles", input.cwd, ["add", "-A", "--", ...input.paths]).pipe(Effect.asVoid); + + const unstageFiles: SourceControlPanelServiceShape["unstageFiles"] = (input) => + run("vcs.panel.unstageFiles", input.cwd, ["reset", "--", ...input.paths]).pipe(Effect.asVoid); + + const discardFiles: SourceControlPanelServiceShape["discardFiles"] = (input) => + Effect.gen(function* () { + const paths = uniquePaths(input.paths); + if (paths.length === 0) return; + if (input.staged) { + const headPaths = yield* run( + "vcs.panel.discardStagedFiles.listHeadPaths", + input.cwd, + ["ls-tree", "-r", "--name-only", "HEAD", "--", ...paths], + { allowNonZeroExit: true }, + ).pipe(Effect.map(parsePathLines)); + const headPathSet = new Set(headPaths); + const pathsInHead = paths.filter((path) => headPathSet.has(path)); + const pathsOutsideHead = paths.filter((path) => !headPathSet.has(path)); + + if (pathsInHead.length > 0) { + yield* run("vcs.panel.discardStagedFiles", input.cwd, [ + "restore", + "--staged", + "--worktree", + "--source=HEAD", + "--", + ...pathsInHead, + ]).pipe(Effect.asVoid); + } + if (pathsOutsideHead.length > 0) { + yield* run("vcs.panel.discardStagedFiles.reset", input.cwd, [ + "reset", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + yield* run("vcs.panel.discardStagedFiles.clean", input.cwd, [ + "clean", + "-fd", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + } + return; + } + + const trackedPaths = yield* run("vcs.panel.discardUnstagedFiles.listIndexPaths", input.cwd, [ + "ls-files", + "--cached", + "--", + ...paths, + ]).pipe(Effect.map(parsePathLines)); + if (trackedPaths.length > 0) { + yield* run("vcs.panel.discardUnstagedFiles", input.cwd, [ + "restore", + "--worktree", + "--", + ...trackedPaths, + ]).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ); + } + yield* run("vcs.panel.cleanUntrackedFiles", input.cwd, ["clean", "-fd", "--", ...paths]).pipe( + Effect.asVoid, + ); + }); + + const readFileDiff: SourceControlPanelServiceShape["readFileDiff"] = Effect.fn("readFileDiff")( + function* (input) { + const source = input.source ?? { + kind: "working-tree" as const, + staged: input.staged ?? false, + }; + if (source.kind === "commit") { + const patch = yield* run("vcs.panel.readCommitFileDiff", input.cwd, [ + "show", + "--format=", + "--no-ext-diff", + "--patch", + "--minimal", + source.sha, + "--", + input.path, + ]); + return { path: input.path, staged: false, patch }; + } + if (source.kind === "compare") { + const patch = yield* run("vcs.panel.readCompareFileDiff", input.cwd, [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + `${source.baseRef}...${source.refName}`, + "--", + input.path, + ]); + return { path: input.path, staged: false, patch }; + } + if (source.kind === "stash") { + const patch = yield* run("vcs.panel.readStashFileDiff", input.cwd, [ + "stash", + "show", + "--patch", + "--include-untracked", + source.stashRef, + "--", + input.path, + ]); + return { path: input.path, staged: false, patch }; + } + + const args = source.staged + ? ["diff", "--cached", "--", input.path] + : ["diff", "--", input.path]; + let patch = yield* run("vcs.panel.readFileDiff", input.cwd, args); + if (!source.staged && patch.trim().length === 0) { + patch = yield* run( + "vcs.panel.readUntrackedFileDiff", + input.cwd, + ["diff", "--no-index", "--", "/dev/null", input.path], + { allowNonZeroExit: true }, + ); + } + return { path: input.path, staged: source.staged, patch }; + }, + ); + + const pushBranchDirect = Effect.fn("pushBranchDirect")(function* ( + cwd: string, + branchName: string, + force: boolean, + publishRemoteName?: string, + ) { + const upstream = (yield* upstreamForRef(cwd, branchName)) ?? ""; + const [remoteName = "origin", ...remoteBranchParts] = + upstream.length > 0 ? upstream.split("/") : [publishRemoteName ?? "origin", branchName]; + const remoteBranchName = remoteBranchParts.join("/") || branchName; + yield* run("vcs.panel.pushBranch", cwd, [ + "push", + ...(force ? ["--force-with-lease"] : []), + "-u", + remoteName, + `${branchName}:refs/heads/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }); + + const commitStaged: SourceControlPanelServiceShape["commitStaged"] = Effect.fn("commitStaged")( + function* (input) { + const args = ["commit", "-m", input.message.trim()]; + yield* run("vcs.panel.commitStaged", input.cwd, args).pipe(Effect.asVoid); + if (input.push) { + const status = yield* workflow + .status({ cwd: input.cwd }) + .pipe( + Effect.mapError( + asGitCommandError("vcs.panel.commitStaged.status", input.cwd, ["status"]), + ), + ); + if (!status.refName) { + return yield* gitError( + "vcs.panel.commitStaged.push", + input.cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + yield* pushBranchDirect(input.cwd, status.refName, false); + } + }, + ); + + const pullBranch: SourceControlPanelServiceShape["pullBranch"] = Effect.fn("pullBranch")( + function* (input) { + const status = yield* workflow + .status({ cwd: input.cwd }) + .pipe( + Effect.mapError(asGitCommandError("vcs.panel.pullBranch.status", input.cwd, ["status"])), + ); + if (status.refName !== input.branchName) { + if (input.merge) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull", "--no-rebase"], + "Merge sync is only available for the current branch.", + ); + } + const upstream = yield* upstreamForRef(input.cwd, input.branchName); + if (!upstream) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull"], + `Branch ${input.branchName} has no upstream.`, + ); + } + const [remoteName = "origin", ...remoteBranchParts] = upstream.split("/"); + const remoteBranchName = remoteBranchParts.join("/"); + if (remoteBranchName.length === 0) { + return yield* gitError( + "vcs.panel.pullBranch", + input.cwd, + ["pull"], + `Branch ${input.branchName} has invalid upstream ${upstream}.`, + ); + } + yield* run("vcs.panel.pullBranch.nonCurrent", input.cwd, [ + "fetch", + remoteName, + `${input.force ? "+" : ""}refs/heads/${remoteBranchName}:refs/heads/${input.branchName}`, + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + if (input.force) { + yield* run("vcs.panel.forcePullBranch", input.cwd, ["fetch"]); + const upstream = yield* run("vcs.panel.forcePullBranch.upstream", input.cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]).pipe(Effect.map((value) => value.trim())); + yield* run("vcs.panel.forcePullBranch.reset", input.cwd, [ + "reset", + "--hard", + upstream, + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + if (input.merge) { + const upstream = yield* run("vcs.panel.mergePullBranch.upstream", input.cwd, [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + ]).pipe(Effect.map((value) => value.trim())); + yield* run("vcs.panel.mergePullBranch", input.cwd, [ + "pull", + "--no-rebase", + "--no-edit", + ]).pipe(Effect.asVoid); + return { + status: "pulled" as const, + refName: input.branchName, + upstreamRef: upstream, + }; + } + return yield* workflow.pullCurrentBranch(input.cwd); + }, + ); + + const pushBranch: SourceControlPanelServiceShape["pushBranch"] = Effect.fn("pushBranch")( + function* (input) { + yield* pushBranchDirect(input.cwd, input.branchName, input.force ?? false, input.remoteName); + }, + ); + + const fetchBranch: SourceControlPanelServiceShape["fetchBranch"] = Effect.fn("fetchBranch")( + function* (input) { + const remotes = yield* run("vcs.panel.fetchBranch.remotes", input.cwd, ["remote"]).pipe( + Effect.map(parseRefLines), + ); + const remotePrefix = remotes.find((remote) => input.branchName.startsWith(`${remote}/`)); + const upstream = remotePrefix + ? `${remotePrefix}/${input.branchName.slice(remotePrefix.length + 1)}` + : yield* upstreamForRef(input.cwd, input.branchName); + const [remoteNameRaw, ...remoteBranchParts] = upstream + ? upstream.split("/") + : [remotes[0] ?? "origin", input.branchName]; + const remoteName = remoteNameRaw ?? "origin"; + const remoteBranchName = remoteBranchParts.join("/") || input.branchName; + yield* run("vcs.panel.fetchBranch", input.cwd, [ + "fetch", + remoteName, + `refs/heads/${remoteBranchName}:refs/remotes/${remoteName}/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }, + ); + + const deleteBranch: SourceControlPanelServiceShape["deleteBranch"] = Effect.fn("deleteBranch")( + function* (input) { + if (input.branch.current) { + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", "-d", input.branch.name], + "Cannot delete the current branch.", + ); + } + if (input.branch.isRemote && input.branch.remoteName) { + const remoteBranchName = input.branch.name.startsWith(`${input.branch.remoteName}/`) + ? input.branch.name.slice(input.branch.remoteName.length + 1) + : input.branch.name; + yield* run("vcs.panel.deleteRemoteBranch", input.cwd, [ + "push", + input.branch.remoteName, + "--delete", + remoteBranchName, + ]).pipe(Effect.asVoid); + return; + } + yield* run("vcs.panel.deleteLocalBranch", input.cwd, [ + "branch", + input.force ? "-D" : "-d", + input.branch.name, + ]).pipe(Effect.asVoid); + }, + ); + + const undoLatestCommit: SourceControlPanelServiceShape["undoLatestCommit"] = Effect.fn( + "undoLatestCommit", + )(function* (input) { + const currentBranch = yield* run("vcs.panel.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((branch) => branch.trim())); + const targetBranch = input.branchName ?? currentBranch; + const resetTarget = input.sha ? `${input.sha}^` : `${targetBranch || "HEAD"}~1`; + + if (!targetBranch || targetBranch === currentBranch) { + yield* run("vcs.panel.undoLatestCommit", input.cwd, ["reset", "--soft", resetTarget]).pipe( + Effect.asVoid, + ); + return; + } + + yield* run("vcs.panel.undoBranchCommit", input.cwd, [ + "branch", + "-f", + targetBranch, + resetTarget, + ]).pipe(Effect.asVoid); + }); + + const revertCommit: SourceControlPanelServiceShape["revertCommit"] = (input) => + run("vcs.panel.revertCommit", input.cwd, ["revert", "--no-edit", input.sha]).pipe( + Effect.asVoid, + ); + + const checkoutCommit: SourceControlPanelServiceShape["checkoutCommit"] = Effect.fn( + "checkoutCommit", + )(function* (input) { + yield* run("vcs.panel.checkoutCommit", input.cwd, ["checkout", "--detach", input.sha]).pipe( + Effect.asVoid, + ); + return { refName: input.sha }; + }); + + const createBranchFromCommit: SourceControlPanelServiceShape["createBranchFromCommit"] = + Effect.fn("createBranchFromCommit")(function* (input) { + const branchName = yield* validateGitPositionalName({ + operation: "vcs.panel.createBranchFromCommit", + cwd: input.cwd, + args: ["branch", "", input.sha], + kind: "Branch name", + value: input.branchName ?? "", + }); + yield* run("vcs.panel.createBranchFromCommit", input.cwd, [ + "branch", + "--", + branchName, + input.sha, + ]).pipe(Effect.asVoid); + return { refName: branchName }; + }); + + const mergeBranchIntoCurrent: SourceControlPanelServiceShape["mergeBranchIntoCurrent"] = ( + input, + ) => + run("vcs.panel.mergeBranchIntoCurrent", input.cwd, ["merge", "--no-edit", input.refName]).pipe( + Effect.asVoid, + ); + + const rebaseCurrentOnto: SourceControlPanelServiceShape["rebaseCurrentOnto"] = (input) => + run("vcs.panel.rebaseCurrentOnto", input.cwd, ["rebase", input.refName]).pipe(Effect.asVoid); + + return SourceControlPanelService.of({ + snapshot, + branchDetails: (input) => + branchDetails(input.cwd, input.branch, input.defaultCompareRef, input.compareBaseRef), + branchCommits: (input) => + branchCommits(input.cwd, input.branch, input.baseRef, input.kind, input.skip, input.limit), + stashDetails: (input) => stashDetails(input.cwd, input.stashRef), + stageFiles, + unstageFiles, + discardFiles, + enrichWorkingTreeFiles, + readFileDiff, + commitStaged, + pullBranch, + pushBranch, + deleteBranch, + undoLatestCommit, + revertCommit, + checkoutCommit, + createBranchFromCommit, + mergeBranchIntoCurrent, + rebaseCurrentOnto, + fetchBranch, + fetchRemote: (input) => + run("vcs.panel.fetchRemote", input.cwd, ["fetch", input.remoteName]).pipe(Effect.asVoid), + fetchAllRemotes: (input) => + run("vcs.panel.fetchAllRemotes", input.cwd, ["fetch", "--all"]).pipe(Effect.asVoid), + addRemote: (input) => + Effect.gen(function* () { + const remoteName = yield* validateGitPositionalName({ + operation: "vcs.panel.addRemote", + cwd: input.cwd, + args: ["remote", "add", "", input.url], + kind: "Remote name", + value: input.name, + }); + yield* run("vcs.panel.addRemote", input.cwd, ["remote", "add", remoteName, input.url]).pipe( + Effect.asVoid, + ); + }), + removeRemote: (input) => + Effect.gen(function* () { + const remoteName = yield* validateGitPositionalName({ + operation: "vcs.panel.removeRemote", + cwd: input.cwd, + args: ["remote", "remove", ""], + kind: "Remote name", + value: input.remoteName, + }); + yield* run("vcs.panel.removeRemote", input.cwd, ["remote", "remove", remoteName]).pipe( + Effect.asVoid, + ); + }), + createStash: (input) => { + const mode = input.mode ?? "all"; + const modeArgs = + mode === "staged" + ? ["--staged"] + : mode === "unstaged" || input.includeUntracked + ? ["--include-untracked", ...(mode === "unstaged" ? ["--keep-index"] : [])] + : []; + return Effect.gen(function* () { + const paths = input.paths ?? []; + const pathArgs = paths.length > 0 ? ["--", ...paths] : []; + const message = + input.message?.trim() || (yield* generatedStashMessage(input.cwd, mode, paths)); + yield* run("vcs.panel.createStash", input.cwd, [ + "stash", + "push", + ...modeArgs, + "-m", + message, + ...pathArgs, + ]).pipe(Effect.asVoid); + }); + }, + applyStash: (input) => + run("vcs.panel.applyStash", input.cwd, [ + "stash", + "apply", + input.stashRef ?? "stash@{0}", + ]).pipe(Effect.asVoid), + popStash: (input) => + run("vcs.panel.popStash", input.cwd, ["stash", "pop", input.stashRef ?? "stash@{0}"]).pipe( + Effect.asVoid, + ), + dropStash: (input) => + run("vcs.panel.dropStash", input.cwd, ["stash", "drop", input.stashRef ?? "stash@{0}"]).pipe( + Effect.asVoid, + ), + compare: (input) => { + const left = targetRef(input.left); + const right = targetRef(input.right); + const range = left && right ? `${left}..${right}` : left || right; + const args = range ? ["diff", "--no-ext-diff", "--patch", "--minimal", range] : ["diff"]; + return run("vcs.panel.compare", input.cwd, args).pipe( + Effect.map((patch): VcsPanelCompareResult => ({ patch })), + ); + }, + }); +}); + +export const layer = Layer.effect(SourceControlPanelService, make()); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 7c5768162a..6883313d91 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -84,6 +84,57 @@ function makeTestLayer(state: { } describe("VcsStatusBroadcaster", () => { + it("ignores Git internal watcher paths", () => { + assert.isTrue(VcsStatusBroadcaster.shouldIgnoreWatchEventPath(".git/FETCH_HEAD")); + assert.isTrue(VcsStatusBroadcaster.shouldIgnoreWatchEventPath(".git/logs/HEAD")); + assert.isFalse(VcsStatusBroadcaster.shouldIgnoreWatchEventPath("src/.gitkeep")); + assert.isFalse(VcsStatusBroadcaster.shouldIgnoreWatchEventPath("src/app.ts")); + }); + + it.effect("batches watcher refresh decisions after ignored roots are filtered", () => + Effect.gen(function* () { + const checkedBatches: string[][] = []; + const refreshes = Array.from( + yield* Stream.runCollect( + VcsStatusBroadcaster.localWatchRefreshSignals( + Stream.make("src/app.ts", "dist/app.js"), + (relativePaths) => + Effect.sync(() => { + checkedBatches.push([...relativePaths]); + return relativePaths.some((relativePath) => relativePath !== "dist/app.js"); + }), + Duration.millis(1), + ), + ).pipe(Effect.timeout("2 seconds")), + ); + + assert.deepStrictEqual(checkedBatches, [["src/app.ts", "dist/app.js"]]); + assert.equal(refreshes.length, 1); + }), + ); + + it.effect("does not refresh when every debounced watcher path is ignored", () => + Effect.gen(function* () { + const checkedBatches: string[][] = []; + const refreshes = Array.from( + yield* Stream.runCollect( + VcsStatusBroadcaster.localWatchRefreshSignals( + Stream.make(".git/FETCH_HEAD", "dist/app.js", "dist/app.css"), + (relativePaths) => + Effect.sync(() => { + checkedBatches.push([...relativePaths]); + return false; + }), + Duration.millis(1), + ), + ).pipe(Effect.timeout("2 seconds")), + ); + + assert.deepStrictEqual(checkedBatches, [["dist/app.js", "dist/app.css"]]); + assert.deepStrictEqual(refreshes, []); + }), + ); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d83dc26fbe..23fc19dafb 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -5,6 +5,8 @@ import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; @@ -22,10 +24,12 @@ import type { import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as VcsProcess from "./VcsProcess.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_MAX_DELAY = Duration.minutes(15); +const VCS_STATUS_WATCH_IGNORED_ROOTS = new Set([".git"]); interface VcsStatusChange { readonly cwd: string; @@ -47,6 +51,11 @@ interface ActiveRemotePoller { readonly subscriberCount: number; } +interface ActiveLocalWatcher { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } @@ -94,11 +103,40 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); +function watchEventPath(path: Path.Path, rawCwd: string, eventPath: string): string | null { + const relativePath = path.isAbsolute(eventPath) ? path.relative(rawCwd, eventPath) : eventPath; + if (!relativePath || relativePath === ".") return null; + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return null; + return relativePath.split(path.sep).join("/"); +} + +export function shouldIgnoreWatchEventPath(relativePath: string): boolean { + const [rootSegment] = relativePath.split("/"); + return rootSegment ? VCS_STATUS_WATCH_IGNORED_ROOTS.has(rootSegment) : false; +} + +export function localWatchRefreshSignals( + relativePaths: Stream.Stream, + shouldRefreshForPaths: (relativePaths: readonly string[]) => Effect.Effect, + debounceDuration: Duration.Duration = Duration.millis(150), +): Stream.Stream { + return relativePaths.pipe( + Stream.filter((relativePath) => !shouldIgnoreWatchEventPath(relativePath)), + Stream.groupedWithin(512, debounceDuration), + Stream.map((paths) => [...new Set(paths)]), + Stream.filter((paths) => paths.length > 0), + Stream.filterEffect(shouldRefreshForPaths), + Stream.map(() => undefined), + ); +} + export const layer = Layer.effect( VcsStatusBroadcaster, Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const vcsProcess = yield* Effect.serviceOption(VcsProcess.VcsProcess); const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub), @@ -108,6 +146,7 @@ export const layer = Layer.effect( ); const cacheRef = yield* Ref.make(new Map()); const pollersRef = yield* SynchronizedRef.make(new Map()); + const watchersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, @@ -410,6 +449,99 @@ export const layer = Layer.effect( } }); + const makeLocalWatchLoop = (cwd: string) => + localWatchRefreshSignals( + fs.watch(cwd).pipe( + Stream.map((event) => watchEventPath(path, cwd, event.path)), + Stream.filter((relativePath): relativePath is string => relativePath !== null), + ), + (relativePaths) => + Option.match(vcsProcess, { + onNone: () => Effect.succeed(true), + onSome: (process) => + process + .run({ + operation: "VcsStatusBroadcaster.watch.checkIgnore", + command: "git", + args: ["check-ignore", "-z", "--stdin"], + cwd, + stdin: `${relativePaths.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 1_000_000, + }) + .pipe( + Effect.map((result) => { + if (result.exitCode !== 0) return true; + const ignoredPaths = new Set( + result.stdout.split("\0").filter((ignoredPath) => ignoredPath.length > 0), + ); + return relativePaths.some((relativePath) => !ignoredPaths.has(relativePath)); + }), + Effect.orElseSucceed(() => true), + ), + }), + ).pipe( + Stream.runForEach(() => refreshLocalStatus(cwd).pipe(Effect.ignoreCause({ log: true }))), + Effect.ignoreCause({ log: true }), + ); + + const retainLocalWatcher = Effect.fn("VcsStatusBroadcaster.retainLocalWatcher")(function* ( + cwd: string, + ) { + yield* SynchronizedRef.modifyEffect(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(cwd); + if (existing) { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextWatchers] as const); + } + + return makeLocalWatchLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextWatchers] as const; + }), + ); + }); + }); + + const releaseLocalWatcher = Effect.fn("VcsStatusBroadcaster.releaseLocalWatcher")(function* ( + cwd: string, + ) { + const watcherToInterrupt = yield* SynchronizedRef.modify(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(cwd); + if (!existing) { + return [null, activeWatchers] as const; + } + + if (existing.subscriberCount > 1) { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextWatchers] as const; + } + + const nextWatchers = new Map(activeWatchers); + nextWatchers.delete(cwd); + return [existing.fiber, nextWatchers] as const; + }); + + if (watcherToInterrupt) { + yield* Fiber.interrupt(watcherToInterrupt).pipe(Effect.ignore); + } + }); + const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { @@ -424,8 +556,11 @@ export const layer = Layer.effect( Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); + yield* retainLocalWatcher(cwd); - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = Effect.all([releaseRemotePoller(cwd), releaseLocalWatcher(cwd)], { + concurrency: "unbounded", + }).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 34c993de84..fcfb25613c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -94,6 +94,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlPanelService from "./sourceControl/SourceControlPanelService.ts"; import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -172,6 +173,35 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelSnapshot, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelBranchDetails, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelBranchCommits, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelStashDetails, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelEnrichWorkingTreeFiles, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelReadFileDiff, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelCompare, AuthOrchestrationReadScope], + [WS_METHODS.vcsPanelCommitStaged, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelStageFiles, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelUnstageFiles, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelDiscardFiles, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelPullBranch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelPushBranch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelDeleteBranch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelUndoLatestCommit, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelRevertCommit, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelCheckoutCommit, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelCreateBranchFromCommit, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelMergeBranchIntoCurrent, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelRebaseCurrentOnto, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelFetchBranch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelFetchRemote, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelFetchAllRemotes, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelAddRemote, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelRemoveRemote, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelCreateStash, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelApplyStash, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelPopStash, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPanelDropStash, AuthOrchestrationOperateScope], [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], @@ -288,6 +318,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), ); const sourceControlRepositories = yield* SourceControlRepositoryService; + const sourceControlPanel = yield* SourceControlPanelService.make(); const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1313,6 +1344,214 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.vcsPanelSnapshot]: (input) => + observeRpcEffect(WS_METHODS.vcsPanelSnapshot, sourceControlPanel.snapshot(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPanelBranchDetails]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelBranchDetails, + sourceControlPanel.branchDetails(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelBranchCommits]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelBranchCommits, + sourceControlPanel.branchCommits(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelStashDetails]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelStashDetails, + sourceControlPanel.stashDetails(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelEnrichWorkingTreeFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelEnrichWorkingTreeFiles, + sourceControlPanel.enrichWorkingTreeFiles(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelReadFileDiff]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelReadFileDiff, + sourceControlPanel.readFileDiff(input), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCompare]: (input) => + observeRpcEffect(WS_METHODS.vcsPanelCompare, sourceControlPanel.compare(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsPanelCommitStaged]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCommitStaged, + sourceControlPanel + .commitStaged(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelStageFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelStageFiles, + sourceControlPanel + .stageFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelUnstageFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelUnstageFiles, + sourceControlPanel + .unstageFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDiscardFiles]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDiscardFiles, + sourceControlPanel + .discardFiles(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPullBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPullBranch, + sourceControlPanel + .pullBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPushBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPushBranch, + sourceControlPanel + .pushBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDeleteBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDeleteBranch, + sourceControlPanel + .deleteBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelUndoLatestCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelUndoLatestCommit, + sourceControlPanel + .undoLatestCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRevertCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRevertCommit, + sourceControlPanel + .revertCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCheckoutCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCheckoutCommit, + sourceControlPanel + .checkoutCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCreateBranchFromCommit]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCreateBranchFromCommit, + sourceControlPanel + .createBranchFromCommit(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelMergeBranchIntoCurrent]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelMergeBranchIntoCurrent, + sourceControlPanel + .mergeBranchIntoCurrent(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRebaseCurrentOnto]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRebaseCurrentOnto, + sourceControlPanel + .rebaseCurrentOnto(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchBranch, + sourceControlPanel + .fetchBranch(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchRemote, + sourceControlPanel + .fetchRemote(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelFetchAllRemotes]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelFetchAllRemotes, + sourceControlPanel + .fetchAllRemotes(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelAddRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelAddRemote, + sourceControlPanel.addRemote(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelRemoveRemote]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelRemoveRemote, + sourceControlPanel + .removeRemote(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelCreateStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelCreateStash, + sourceControlPanel + .createStash(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelApplyStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelApplyStash, + sourceControlPanel + .applyStash(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelPopStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelPopStash, + sourceControlPanel.popStash(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), + [WS_METHODS.vcsPanelDropStash]: (input) => + observeRpcEffect( + WS_METHODS.vcsPanelDropStash, + sourceControlPanel.dropStash(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 52f2594551..3a52ae3f9d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -111,6 +111,11 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((mod) => ({ default: mod.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const SourceControlPanel = lazy(() => + import("./source-control/SourceControlPanel").then((mod) => ({ + default: mod.SourceControlPanel, + })), +); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); import { BranchToolbar } from "./BranchToolbar"; @@ -132,6 +137,7 @@ import { import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; +import { useHostDisplayPreferences } from "../hostDisplayPreferences"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { @@ -1088,6 +1094,8 @@ function ChatViewContent(props: ChatViewProps) { routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, ); const settings = useSettings(); + const hostDisplayPreferences = useHostDisplayPreferences(); + const sourceControlPanelEnabled = hostDisplayPreferences.enableSourceControlPanel; const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, ); @@ -1292,8 +1300,19 @@ function ChatViewContent(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((store) => selectActiveRightPanelSurface(store.byThreadKey, activeThreadRef), ); + const visibleRightPanelSurfaces = useMemo( + () => + rightPanelState.surfaces.filter( + (surface) => surface.kind !== "source-control" || sourceControlPanelEnabled, + ), + [rightPanelState.surfaces, sourceControlPanelEnabled], + ); + const visibleActiveRightPanelSurface = + activeRightPanelSurface?.kind === "source-control" && !sourceControlPanelEnabled + ? null + : activeRightPanelSurface; const activeFileSurface = - activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; + visibleActiveRightPanelSurface?.kind === "file" ? visibleActiveRightPanelSurface : null; const activePreviewState = usePreviewStateStore((state) => selectThreadPreviewState(state.byThreadKey, activeThreadRef), ); @@ -1662,6 +1681,31 @@ function ChatViewContent(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + const handleSourceControlThreadRefChange = useCallback( + async (input: { branch: string | null; worktreePath: string | null }) => { + if (!activeThread) return; + if (isServerThread) { + const api = readEnvironmentApi(activeThread.environmentId); + if (!api) return; + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThread.id, + branch: input.branch, + worktreePath: input.worktreePath, + }); + return; + } + + setDraftThreadContext(composerDraftTarget, { + branch: input.branch, + worktreePath: input.worktreePath, + envMode: input.worktreePath ? "worktree" : "local", + }); + }, + [activeThread, composerDraftTarget, isServerThread, setDraftThreadContext], + ); + useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; @@ -2260,6 +2304,7 @@ function ChatViewContent(props: ChatViewProps) { terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const sourceControlAvailable = Boolean(sourceControlPanelEnabled && gitCwd && isGitRepo); const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -2323,6 +2368,10 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); }; + const addSourceControlSurface = useCallback(() => { + if (!activeThreadRef || !sourceControlAvailable) return; + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + }, [activeThreadRef, sourceControlAvailable]); const openFileSurface = (relativePath: string) => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().openFile(activeThreadRef, relativePath); @@ -4710,20 +4759,20 @@ function ChatViewContent(props: ChatViewProps) { ); const rightPanelContent = activeThreadRef ? ( - activeRightPanelSurface?.kind === "preview" ? ( + visibleActiveRightPanelSurface?.kind === "preview" ? ( - ) : activeRightPanelSurface?.kind === "terminal" ? ( + ) : visibleActiveRightPanelSurface?.kind === "terminal" ? ( - ) : activeRightPanelSurface?.kind === "diff" ? ( + ) : visibleActiveRightPanelSurface?.kind === "diff" ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( + ) : visibleActiveRightPanelSurface?.kind === "plan" ? ( - ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && + ) : visibleActiveRightPanelSurface?.kind === "source-control" && gitCwd ? ( + + + + ) : (visibleActiveRightPanelSurface?.kind === "files" || + visibleActiveRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( @@ -4768,7 +4828,9 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} relativePath={ - activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath : null + visibleActiveRightPanelSurface.kind === "file" + ? visibleActiveRightPanelSurface.relativePath + : null } revealLine={activeFileSurface?.revealLine ?? null} revealRequestId={activeFileSurface?.revealRequestId ?? 0} @@ -5034,8 +5096,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} @@ -5062,8 +5126,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index ead306f7c9..285dd896f8 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + ClipboardList, + FileDiff, + Files, + GitBranch, + Globe2, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -43,9 +52,11 @@ interface RightPanelTabsProps { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddSourceControl: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + sourceControlAvailable: boolean; children: ReactNode; } @@ -53,6 +64,7 @@ const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", + sourceControl: "Version Control is only available when a project is open in a Git repository.", } as const; type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; @@ -90,11 +102,21 @@ function RightPanelEmptyState(props: { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddSourceControl: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + sourceControlAvailable: boolean; }) { const actions = [ + { + label: "Version Control", + description: "Review repository changes and sync state.", + icon: GitBranch, + available: props.sourceControlAvailable, + disabledReason: SURFACE_DISABLED_REASONS.sourceControl, + onClick: props.onAddSourceControl, + }, { label: "Browser", description: "Open a local app or URL.", @@ -204,6 +226,8 @@ function surfaceTitle( ); case "plan": return "Plan"; + case "source-control": + return "Version Control"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -265,6 +289,8 @@ function SurfaceIcon({ return ; case "plan": return ; + case "source-control": + return ; } } @@ -453,6 +479,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { Diff + + + Version Control + ) : null} @@ -467,9 +501,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} onAddFiles={props.onAddFiles} + onAddSourceControl={props.onAddSourceControl} browserAvailable={props.browserAvailable} diffAvailable={props.diffAvailable} filesAvailable={props.filesAvailable} + sourceControlAvailable={props.sourceControlAvailable} /> ) : ( props.children diff --git a/apps/web/src/components/source-control/SourceControlPanel.tsx b/apps/web/src/components/source-control/SourceControlPanel.tsx new file mode 100644 index 0000000000..4084872bd6 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -0,0 +1,4110 @@ +import type { + ContextMenuItem, + EnvironmentId, + VcsPanelBranchCommitsInput, + ThreadId, + VcsPanelBranchDetails, + VcsPanelChangeGroup, + VcsPanelCommitSummary, + VcsPanelFileDiffInput, + VcsPanelFileChange, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsPanelStashDetails, + VcsPanelWorkingTreeFileEnrichmentResult, + VcsRef, +} from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { FileDiff } from "@pierre/diffs/react"; +import { + Archive, + AlertTriangle, + ChevronDown, + ChevronRight, + Copy, + Download, + FileText, + GitBranch, + GitBranchPlus, + GitCommit, + GitCompare, + GitMerge, + GitPullRequestArrow, + LoaderCircle, + Plus, + RefreshCw, + RotateCcw, + Tag, + Target, + Trash2, + Undo2, + Upload, +} from "lucide-react"; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactNode, +} from "react"; +import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; + +import { openInPreferredEditor } from "~/editorPreferences"; +import { readEnvironmentApi } from "~/environmentApi"; +import { useTheme } from "~/hooks/useTheme"; +import { readLocalApi } from "~/localApi"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { invalidateSourceControlState, useGitStackedAction } from "~/lib/sourceControlActions"; +import { cn, newCommandId } from "~/lib/utils"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { useVcsStatus } from "~/lib/vcsStatusState"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { shouldIncludeBranchPickerItem } from "../BranchToolbar.logic"; +import { VisualStudioCode } from "../Icons"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface SourceControlPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly onThreadRefChange?: (input: { + readonly branch: string | null; + readonly worktreePath: string | null; + }) => Promise | void; +} + +type FileDiffSource = NonNullable; +type BranchCommitListKind = NonNullable; + +type FileDiffLoadState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly patch: string } + | { readonly status: "error"; readonly message: string }; + +type SectionKey = "work" | "remotes"; + +const SECTION_ORDER: readonly SectionKey[] = ["work", "remotes"]; + +const SECTION_TITLES: Record = { + work: "Actionable", + remotes: "Remotes", +}; + +const DEFAULT_SECTION_WEIGHTS: Record = { + work: 3, + remotes: 1.4, +}; + +const COLLAPSED_SECTION_HEIGHT = 32; +const MIN_SECTION_WEIGHT = 0.35; +const COMMIT_PAGE_SIZE = 10; +const WORKING_FILE_ROW_ESTIMATED_HEIGHT = 28; +const WORKING_FILE_DRAW_DISTANCE = 600; +const commitDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Source control action failed."; +} + +interface PanelChangedFile extends VcsPanelFileChange { + readonly hasStagedChanges: boolean; + readonly hasUnstagedChanges: boolean; + readonly hasConflicts: boolean; +} + +function mergedFileStatus( + statuses: ReadonlySet, +): VcsPanelFileChange["status"] { + if (statuses.has("conflicted")) return "conflicted"; + if (statuses.has("deleted")) return "deleted"; + if (statuses.has("renamed")) return "renamed"; + if (statuses.has("copied")) return "copied"; + if (statuses.has("added")) return "added"; + if (statuses.has("untracked")) return "untracked"; + return "modified"; +} + +function mergeChangeGroups(groups: readonly VcsPanelChangeGroup[]): PanelChangedFile[] { + const files = new Map< + string, + { + originalPath: string | null; + statuses: Set; + insertions: number; + deletions: number; + hasStagedChanges: boolean; + hasUnstagedChanges: boolean; + hasConflicts: boolean; + } + >(); + + for (const group of groups) { + for (const file of group.files) { + const existing = files.get(file.path) ?? { + originalPath: file.originalPath, + statuses: new Set(), + insertions: 0, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: false, + hasConflicts: false, + }; + existing.originalPath ??= file.originalPath; + existing.statuses.add(file.status); + existing.insertions = Math.max(existing.insertions, file.insertions); + existing.deletions = Math.max(existing.deletions, file.deletions); + existing.hasStagedChanges ||= group.kind === "staged"; + existing.hasUnstagedChanges ||= group.kind === "unstaged"; + existing.hasConflicts ||= group.kind === "conflicts"; + files.set(file.path, existing); + } + } + + return [...files.entries()] + .map(([path, file]) => ({ + path, + originalPath: file.originalPath, + status: mergedFileStatus(file.statuses), + insertions: file.insertions, + deletions: file.deletions, + hasStagedChanges: file.hasStagedChanges, + hasUnstagedChanges: file.hasUnstagedChanges, + hasConflicts: file.hasConflicts, + })) + .toSorted((left, right) => left.path.localeCompare(right.path)); +} + +function applyWorkingTreeFileEnrichment( + groups: readonly VcsPanelChangeGroup[], + enrichedFilesByPath: ReadonlyMap, + hiddenPaths: ReadonlySet, +): VcsPanelChangeGroup[] { + if (enrichedFilesByPath.size === 0 && hiddenPaths.size === 0) { + return groups.map((group) => ({ ...group, files: [...group.files] })); + } + return groups.map((group) => { + if (group.kind !== "unstaged") return { ...group, files: [...group.files] }; + const seenPaths = new Set(); + const files = group.files.flatMap((file) => { + if (hiddenPaths.has(file.path)) return []; + const enrichedFile = enrichedFilesByPath.get(file.path) ?? file; + seenPaths.add(enrichedFile.path); + return [enrichedFile]; + }); + for (const enrichedFile of enrichedFilesByPath.values()) { + if (seenPaths.has(enrichedFile.path) || hiddenPaths.has(enrichedFile.path)) continue; + files.push(enrichedFile); + } + return { + ...group, + files: files.toSorted((left, right) => left.path.localeCompare(right.path)), + }; + }); +} + +function shouldEnrichWorkingTreeFile(file: PanelChangedFile): boolean { + return file.hasUnstagedChanges && (file.status === "untracked" || file.status === "deleted"); +} + +function isActionForced(event: ReactMouseEvent): boolean { + return event.shiftKey; +} + +function shouldFetchBeforePull(event: ReactMouseEvent): boolean { + return event.altKey; +} + +function commitUndoActionKey(branchName: string, sha?: string): string { + return sha ? `commit-undo:${branchName}:${sha}` : `branch-undo-latest:${branchName}`; +} + +function branchSyncCounts( + branch: VcsRef, + snapshot: VcsPanelSnapshotResult, +): { readonly aheadCount: number; readonly behindCount: number } { + if (branch.current) { + return { + aheadCount: snapshot.status.aheadCount, + behindCount: snapshot.status.behindCount, + }; + } + return { + aheadCount: branch.aheadCount ?? 0, + behindCount: branch.behindCount ?? 0, + }; +} + +function branchHasUpstream(branch: VcsRef, snapshot: VcsPanelSnapshotResult): boolean { + return branch.current ? snapshot.status.hasUpstream : Boolean(branch.upstreamName); +} + +function treeKey(kind: string, id: string): string { + return `${kind}:${id}`; +} + +function formatRelativeDate(value: string | null | undefined): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + const elapsedMs = Date.now() - time; + if (elapsedMs < 60_000) return "just now"; + const minutes = Math.floor(elapsedMs / 60_000); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + if (days === 1) return "yesterday"; + if (days < 7) return `${days} days ago`; + const weeks = Math.floor(days / 7); + if (weeks === 1) return "last week"; + if (days < 30) return `${weeks} weeks ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`; + const years = Math.floor(days / 365); + return `${years} year${years === 1 ? "" : "s"} ago`; +} + +function formatReadableDate(value: string | null | undefined): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + return commitDateFormatter.format(new Date(time)); +} + +function mapBranchDetails( + details: readonly VcsPanelBranchDetails[], +): ReadonlyMap { + const map = new Map(); + for (const detail of details) { + map.set(detail.fullRefName, detail); + map.set(detail.name, detail); + } + return map; +} + +function remoteBranchRef( + remote: VcsPanelRemote, + branch: VcsPanelRemote["branches"][number], +): VcsRef { + return { + name: branch.fullRefName, + isRemote: true, + remoteName: remote.name, + current: false, + isDefault: branch.isDefaultRemoteHead, + worktreePath: null, + lastActivityAt: branch.lastActivityAt, + upstreamName: null, + }; +} + +function localBranchForRemoteBranch( + snapshot: VcsPanelSnapshotResult, + remote: VcsPanelRemote, + branch: VcsPanelRemote["branches"][number], +): VcsRef | null { + return ( + snapshot.localBranches.find((localBranch) => localBranch.upstreamName === branch.fullRefName) ?? + snapshot.localBranches.find( + (localBranch) => + localBranch.name === branch.name && + localBranch.upstreamName === `${remote.name}/${branch.name}`, + ) ?? + null + ); +} + +function localOnlyBranches(snapshot: VcsPanelSnapshotResult): VcsRef[] { + return snapshot.localBranches + .filter((branch) => !branchHasUpstream(branch, snapshot)) + .toSorted((left, right) => branchActivityTimestamp(right) - branchActivityTimestamp(left)); +} + +function compareBaseRefNames(snapshot: VcsPanelSnapshotResult | null): string[] { + if (!snapshot) return []; + const refs = new Set(); + if (snapshot.defaultCompareRef) refs.add(snapshot.defaultCompareRef); + for (const branch of snapshot.localBranches) { + refs.add(branch.name); + if (branch.upstreamName) refs.add(branch.upstreamName); + } + for (const remote of snapshot.remotes) { + for (const branch of remote.branches) { + refs.add(branch.fullRefName); + } + } + return [...refs].toSorted((left, right) => left.localeCompare(right)); +} + +type ExpandedBranchRequest = { + readonly branch: VcsRef; + readonly detailsKey: string; + readonly compareBaseRef?: string; +}; + +function expandedBranchesForSnapshot( + snapshot: VcsPanelSnapshotResult, + expanded: ReadonlySet, +): ExpandedBranchRequest[] { + const localBranches = snapshot.localBranches + .filter((branch) => expanded.has(treeKey("branch", branch.name))) + .map((branch) => ({ branch, detailsKey: branch.name })); + const expandedLocalBranches = localOnlyBranches(snapshot) + .filter((branch) => expanded.has(treeKey("remote-branch", `local:${branch.name}`))) + .map((branch) => ({ branch, detailsKey: branch.name })); + const remoteBranches = snapshot.remotes.flatMap((remote) => + remote.branches + .map((branch) => ({ + displayName: branch.name, + ref: + localBranchForRemoteBranch(snapshot, remote, branch) ?? remoteBranchRef(remote, branch), + })) + .filter((branch) => + expanded.has( + treeKey("remote-branch", `${branch.ref.remoteName ?? "local"}:${branch.displayName}`), + ), + ) + .map((branch) => ({ branch: branch.ref, detailsKey: branch.ref.name })), + ); + const forkBranches = snapshot.actionableForkBranches.flatMap((fork) => { + const branch = snapshot.localBranches.find( + (localBranch) => localBranch.name === fork.localBranchName, + ); + if (!branch) return []; + const detailsKey = treeKey("fork-details", `${fork.localBranchName}:${fork.remoteRefName}`); + return expanded.has(treeKey("fork-branch", `${fork.localBranchName}:${fork.remoteRefName}`)) + ? [{ branch, detailsKey, compareBaseRef: fork.remoteRefName }] + : []; + }); + return [...localBranches, ...expandedLocalBranches, ...remoteBranches, ...forkBranches]; +} + +function StatLabels({ + insertions, + deletions, +}: { + readonly insertions: number; + readonly deletions: number; +}) { + if (insertions === 0 && deletions === 0) return null; + return ( + + {insertions > 0 ? +{insertions} : null} + {deletions > 0 ? -{deletions} : null} + + ); +} + +function BranchSyncLabels({ + aheadCount, + behindCount, +}: { + readonly aheadCount: number; + readonly behindCount: number; +}) { + if (aheadCount === 0 && behindCount === 0) return null; + return ( + + {aheadCount > 0 ? ↑{aheadCount} : null} + {behindCount > 0 ? ↓{behindCount} : null} + + ); +} + +type BranchSyncState = "fetch" | "pull" | "push" | "publish" | "diverged"; + +function branchSyncState(branch: VcsRef, snapshot: VcsPanelSnapshotResult): BranchSyncState { + const hasUpstream = branchHasUpstream(branch, snapshot); + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + if (!hasUpstream) return "publish"; + if (aheadCount > 0 && behindCount > 0) return "diverged"; + if (behindCount > 0) return "pull"; + if (aheadCount > 0) return "push"; + return "fetch"; +} + +function branchSyncActionLabel(state: BranchSyncState): string { + switch (state) { + case "publish": + return "Publish"; + case "pull": + return "Pull. Shift: reset. Option: fetch."; + case "push": + return "Push"; + case "diverged": + return "Sync diverged"; + case "fetch": + return "Fetch"; + } +} + +function BranchSyncActionIcon({ state }: { readonly state: BranchSyncState }) { + switch (state) { + case "publish": + return ; + case "pull": + return ; + case "push": + return ; + case "diverged": + return ; + case "fetch": + return ; + } +} + +type AttentionKind = "conflicts" | "diverged" | "behind" | "unpushed" | "dirty" | "stale"; + +const ATTENTION_RANK: Record = { + conflicts: 0, + diverged: 1, + behind: 2, + unpushed: 3, + dirty: 4, + stale: 5, +}; + +function branchAttention(branch: VcsRef, snapshot: VcsPanelSnapshotResult): AttentionKind { + const hasUpstream = branchHasUpstream(branch, snapshot); + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + if (aheadCount > 0 && behindCount > 0) return "diverged"; + if (behindCount > 0) return "behind"; + if (aheadCount > 0 || !hasUpstream) return "unpushed"; + return "stale"; +} + +function branchActivityTimestamp(branch: { + readonly lastActivityAt?: string | null | undefined; +}): number { + if (!branch.lastActivityAt) return 0; + const time = Date.parse(branch.lastActivityAt); + return Number.isFinite(time) ? time : 0; +} + +function stashActivityTimestamp(stash: VcsPanelStash): number { + if (!stash.createdAt) return 0; + const time = Date.parse(stash.createdAt); + return Number.isFinite(time) ? time : 0; +} + +function AttentionIcon({ kind }: { readonly kind: AttentionKind }) { + switch (kind) { + case "conflicts": + case "diverged": + return ; + case "behind": + return ; + case "unpushed": + return ; + case "dirty": + return ; + case "stale": + return ; + } +} + +function AuthorAvatar({ + commit, + className, +}: { + readonly commit: VcsPanelCommitSummary; + readonly className?: string; +}) { + const [failed, setFailed] = useState(false); + const fallbackText = + commit.authorName + ?.trim() + .split(/\s+/u) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") || + commit.authorEmail?.trim()[0]?.toUpperCase() || + "?"; + const avatarClassName = cn( + "inline-flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-medium text-muted-foreground object-cover", + className, + ); + if (!commit.authorAvatarUrl || failed) { + return ( + + {fallbackText} + + ); + } + return ( + {commit.authorName { + event.currentTarget.hidden = true; + setFailed(true); + }} + /> + ); +} + +type DisplayHeadRef = + | { readonly kind: "local"; readonly name: string; readonly synced: boolean } + | { readonly kind: "remote"; readonly name: string }; + +function displayHeadRefs(headRefs: readonly string[]): DisplayHeadRef[] { + const localRefs = new Set(headRefs.filter((ref) => !ref.includes("/"))); + const remoteByBranch = new Map(); + for (const ref of headRefs) { + const slashIndex = ref.indexOf("/"); + if (slashIndex <= 0) continue; + const branchName = ref.slice(slashIndex + 1); + if (branchName.length === 0 || branchName === "HEAD") continue; + remoteByBranch.set(branchName, ref); + } + + const refs: DisplayHeadRef[] = [...localRefs] + .toSorted((left, right) => left.localeCompare(right)) + .map((name) => ({ + kind: "local" as const, + name, + synced: remoteByBranch.has(name), + })); + + for (const branchName of [...remoteByBranch.keys()].toSorted((left, right) => + left.localeCompare(right), + )) { + if (localRefs.has(branchName)) continue; + refs.push({ kind: "remote", name: branchName }); + } + + return refs; +} + +function SyncedIcon({ className }: { readonly className?: string }) { + return ; +} + +function RefLabels({ commit }: { readonly commit: VcsPanelCommitSummary }) { + const headRefs = displayHeadRefs(commit.headRefs); + if (headRefs.length === 0 && commit.tags.length === 0) return null; + return ( + + {headRefs.map((ref) => ( + + + {ref.kind === "remote" || (ref.kind === "local" && ref.synced) ? : null} + {ref.name} + + + ))} + {commit.tags.map((tag) => ( + + + + {tag} + + + ))} + + ); +} + +function CommitTooltip({ commit }: { readonly commit: VcsPanelCommitSummary }) { + const relativeDate = formatRelativeDate(commit.authoredAt); + const readableDate = formatReadableDate(commit.authoredAt); + return ( +
+
+ +
+
{commit.authorName ?? "Unknown author"}
+
+ {commit.shortSha} +
+
+
+ {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
{commit.message}
+ + +
+ ); +} + +function IconButton({ + label, + children, + disabled, + destructive, + loading, + onClick, +}: { + readonly label: string; + readonly children: ReactNode; + readonly disabled?: boolean; + readonly destructive?: boolean; + readonly loading?: boolean; + readonly onClick?: (event: ReactMouseEvent) => void; +}) { + return ( + + + {loading ? : children} + + } + /> + {label} + + ); +} + +function RowActions({ children }: { readonly children: ReactNode }) { + return ( +
event.stopPropagation()} + > + {children} +
+ ); +} + +function CompactBadge({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} + +function fileStatusLetter(status: VcsPanelFileChange["status"]): string { + switch (status) { + case "added": + case "untracked": + return "A"; + case "deleted": + return "D"; + case "renamed": + return "R"; + case "copied": + return "C"; + case "conflicted": + return "U"; + case "modified": + return "M"; + } +} + +function fileStatusColor(status: VcsPanelFileChange["status"]): string { + switch (status) { + case "added": + case "untracked": + return "text-success-foreground"; + case "deleted": + case "conflicted": + return "text-destructive-foreground"; + default: + return "text-muted-foreground"; + } +} + +function CollapsibleSection({ + sectionKey, + title, + collapsed, + weight, + onToggle, + onResizeStart, + children, + action, +}: { + readonly sectionKey: SectionKey; + readonly title: string; + readonly collapsed: boolean; + readonly weight: number; + readonly onToggle: () => void; + readonly onResizeStart: (key: SectionKey, event: ReactMouseEvent) => void; + readonly children: ReactNode; + readonly action?: ReactNode; +}) { + return ( +
+
+ + {action} +
+ {!collapsed ? ( +
+ {children} +
+ ) : null} + {!collapsed ? ( +
onResizeStart(sectionKey, event)} + /> + ) : null} +
+ ); +} + +function BranchBadge({ snapshot }: { readonly snapshot: VcsPanelSnapshotResult }) { + const status = snapshot.status; + if (!status.hasUpstream) { + return ( + + No upstream + + ); + } + if (status.aheadCount === 0 && status.behindCount === 0) { + return ( + + Synced + + ); + } + return ; +} + +function sumFiles(files: readonly VcsPanelFileChange[]) { + return files.reduce( + (total, file) => ({ + insertions: total.insertions + file.insertions, + deletions: total.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +function fileBasename(path: string): string { + const parts = path.split(/[\\/]/); + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part) return part; + } + return path; +} + +function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +function operationPathsForFile(file: Pick): string[] { + return uniquePaths(file.originalPath ? [file.path, file.originalPath] : [file.path]); +} + +function commitCountLabel(count: number): string { + return count === 1 ? "1 commit" : `${count} commits`; +} + +function stashBranchName(stash: VcsPanelStash): string | null { + return /^(?:WIP\s+)?on\s+([^:]+):/i.exec(stash.message)?.[1]?.trim() ?? null; +} + +function contextMenuSeparator(id: T): ContextMenuItem { + return { id, label: "", separator: true }; +} + +function FileChangeSummary({ files }: { readonly files: readonly VcsPanelFileChange[] }) { + const stats = sumFiles(files); + return ( + + {files.length === 1 ? "1 file" : `${files.length} files`} + + + ); +} + +function FileChangeList({ + files, + emptyLabel, + onFileContextMenu, + getFileKey, + isFileExpanded, + onFileToggle, + renderExpandedFile, + onOpenFile, + onOpenInVsCode, +}: { + readonly files: readonly VcsPanelFileChange[]; + readonly emptyLabel: string; + readonly onFileContextMenu?: ( + event: ReactMouseEvent, + file: VcsPanelFileChange, + ) => void; + readonly getFileKey?: (file: VcsPanelFileChange) => string; + readonly isFileExpanded?: (file: VcsPanelFileChange) => boolean; + readonly onFileToggle?: (file: VcsPanelFileChange) => void; + readonly renderExpandedFile?: (file: VcsPanelFileChange) => ReactNode; + readonly onOpenFile?: (file: VcsPanelFileChange) => void; + readonly onOpenInVsCode?: (file: VcsPanelFileChange) => void; +}) { + if (files.length === 0) { + return
{emptyLabel}
; + } + return ( +
+ {files.map((file) => { + const fileKey = getFileKey?.(file) ?? `${file.path}:${file.status}`; + const expanded = isFileExpanded?.(file) ?? false; + return ( +
+
onFileToggle(file) : undefined} + onKeyDown={ + onFileToggle + ? (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onFileToggle(file); + } + : undefined + } + onContextMenu={ + onFileContextMenu ? (event) => onFileContextMenu(event, file) : undefined + } + > + {onFileToggle ? ( + expanded ? ( + + ) : ( + + ) + ) : null} + + {fileStatusLetter(file.status)} + + {file.path} + + {onOpenFile || onOpenInVsCode ? ( + + {onOpenFile ? ( + onOpenFile(file)}> + + + ) : null} + {onOpenInVsCode ? ( + onOpenInVsCode(file)}> + + + ) : null} + + ) : null} +
+ {expanded && renderExpandedFile ? ( +
{renderExpandedFile(file)}
+ ) : null} +
+ ); + })} +
+ ); +} + +function LoadMoreCommitsButton({ + remaining, + loading, + onClick, +}: { + readonly remaining: number; + readonly loading: boolean; + readonly onClick: () => void; +}) { + if (remaining <= 0) return null; + return ( + + ); +} + +function WorkingFileVirtualRow({ + file, + onRendered, + renderFile, +}: { + readonly file: PanelChangedFile; + readonly onRendered: (file: PanelChangedFile) => void; + readonly renderFile: (file: PanelChangedFile) => ReactNode; +}) { + useEffect(() => { + onRendered(file); + }, [file, onRendered]); + return <>{renderFile(file)}; +} + +function InlineFileDiff({ + patch, + resolvedTheme, +}: { + readonly patch: string; + readonly resolvedTheme: "light" | "dark"; +}) { + const renderablePatch = useMemo( + () => getRenderablePatch(patch, `vcs-panel-file:${resolvedTheme}`), + [patch, resolvedTheme], + ); + if (!renderablePatch) { + return
No diff.
; + } + if (renderablePatch.kind === "raw") { + return ( +
+        {renderablePatch.text}
+      
+ ); + } + return ( +
+ {renderablePatch.files.map((fileDiff) => ( + + ))} +
+ ); +} + +export function SourceControlPanel({ + cwd, + environmentId, + onThreadRefChange, + threadId, + worktreePath, +}: SourceControlPanelProps) { + const api = useMemo(() => readEnvironmentApi(environmentId), [environmentId]); + const { resolvedTheme } = useTheme(); + const gitActionScope = useMemo(() => ({ environmentId, cwd }), [cwd, environmentId]); + const threadRef = useMemo(() => ({ environmentId, threadId }), [environmentId, threadId]); + const gitAction = useGitStackedAction(gitActionScope); + const vcsStatus = useVcsStatus(gitActionScope); + const containerRef = useRef(null); + const expandedTreeRef = useRef>(new Set()); + const lastFocusRefreshAtRef = useRef(0); + const lastVcsStatusFingerprintRef = useRef(null); + const previousChangedPathsRef = useRef>(new Set()); + const refreshInFlightRef = useRef(false); + const refreshQueuedRef = useRef(false); + const enrichedWorkingTreeFilesRef = useRef>(new Map()); + const hiddenWorkingTreePathsRef = useRef>(new Set()); + const pendingWorkingTreeEnrichmentPathsRef = useRef>(new Set()); + const inFlightWorkingTreeEnrichmentPathsRef = useRef>(new Set()); + const workingTreeEnrichmentTimerRef = useRef(null); + const workingTreeEnrichmentGenerationRef = useRef(0); + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [runningActions, setRunningActions] = useState>(() => new Set()); + const [error, setError] = useState(null); + const [collapsed, setCollapsed] = useState>(() => new Set(["remotes"])); + const [sectionWeights, setSectionWeights] = useState(DEFAULT_SECTION_WEIGHTS); + const [expandedTree, setExpandedTree] = useState>(() => new Set()); + const [collapsedDefaultTree, setCollapsedDefaultTree] = useState>( + () => new Set(), + ); + const [branchDetailsByRef, setBranchDetailsByRef] = useState< + ReadonlyMap + >(() => new Map()); + const [compareBaseOverrides, setCompareBaseOverrides] = useState>( + () => new Map(), + ); + const [loadingBranchDetails, setLoadingBranchDetails] = useState>( + () => new Set(), + ); + const [stashDetailsByRef, setStashDetailsByRef] = useState< + ReadonlyMap + >(() => new Map()); + const [loadingStashDetails, setLoadingStashDetails] = useState>( + () => new Set(), + ); + const [expandedFileDiffs, setExpandedFileDiffs] = useState>(() => new Set()); + const [fileDiffsByKey, setFileDiffsByKey] = useState>( + () => new Map(), + ); + const [enrichedWorkingTreeFilesByPath, setEnrichedWorkingTreeFilesByPath] = useState< + ReadonlyMap + >(() => new Map()); + const [hiddenWorkingTreePaths, setHiddenWorkingTreePaths] = useState>( + () => new Set(), + ); + const [addRemoteOpen, setAddRemoteOpen] = useState(false); + const [commitDialogOpen, setCommitDialogOpen] = useState(false); + const [divergedSyncBranch, setDivergedSyncBranch] = useState(null); + const [publishRemoteTarget, setPublishRemoteTarget] = useState<{ + readonly branch: VcsRef; + readonly force: boolean; + } | null>(null); + const [compareBaseDialogTarget, setCompareBaseDialogTarget] = useState<{ + readonly branch: VcsRef; + readonly detailsKey: string; + } | null>(null); + const [compareBaseQuery, setCompareBaseQuery] = useState(""); + const [dialogCommitMessage, setDialogCommitMessage] = useState(""); + const [stashDialogTarget, setStashDialogTarget] = useState<{ + readonly label: string; + readonly paths: readonly string[]; + } | null>(null); + const [dialogStashMessage, setDialogStashMessage] = useState(""); + const [remoteName, setRemoteName] = useState(""); + const [remoteUrl, setRemoteUrl] = useState(""); + const [selectedChangePaths, setSelectedChangePaths] = useState>( + () => new Set(), + ); + const displayedChangeGroups = useMemo( + () => + applyWorkingTreeFileEnrichment( + snapshot?.changeGroups ?? [], + enrichedWorkingTreeFilesByPath, + hiddenWorkingTreePaths, + ), + [enrichedWorkingTreeFilesByPath, hiddenWorkingTreePaths, snapshot?.changeGroups], + ); + const changedFiles = useMemo( + () => mergeChangeGroups(displayedChangeGroups), + [displayedChangeGroups], + ); + const compareBaseRefs = useMemo(() => compareBaseRefNames(snapshot), [snapshot]); + const deferredCompareBaseQuery = useDeferredValue(compareBaseQuery); + const normalizedCompareBaseQuery = deferredCompareBaseQuery.trim().toLowerCase(); + const filteredCompareBaseRefs = useMemo( + () => + compareBaseRefs.filter((itemValue) => + shouldIncludeBranchPickerItem({ + itemValue, + normalizedQuery: normalizedCompareBaseQuery, + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ), + [compareBaseRefs, normalizedCompareBaseQuery], + ); + const changedPaths = useMemo(() => changedFiles.map((file) => file.path), [changedFiles]); + const selectedChangedFiles = useMemo( + () => changedFiles.filter((file) => selectedChangePaths.has(file.path)), + [changedFiles, selectedChangePaths], + ); + const selectedChangePathList = useMemo( + () => uniquePaths(selectedChangedFiles.flatMap((file) => operationPathsForFile(file))), + [selectedChangedFiles], + ); + const selectedChangeStats = useMemo(() => sumFiles(selectedChangedFiles), [selectedChangedFiles]); + const workingFileListExtraData = useMemo( + () => ({ + expandedFileDiffs, + fileDiffsByKey, + runningActions, + selectedChangePaths, + }), + [expandedFileDiffs, fileDiffsByKey, runningActions, selectedChangePaths], + ); + const allChangedFilesSelected = + changedFiles.length > 0 && selectedChangedFiles.length === changedFiles.length; + const noChangedFilesSelected = selectedChangedFiles.length === 0; + const partialChangedFilesSelected = + changedFiles.length > 0 && !allChangedFilesSelected && !noChangedFilesSelected; + const toggleAllChangedFilesSelection = useCallback(() => { + setSelectedChangePaths(allChangedFilesSelected ? new Set() : new Set(changedPaths)); + }, [allChangedFilesSelected, changedPaths]); + const vcsStatusFingerprint = useMemo(() => { + const status = vcsStatus.data; + if (!status) return null; + return JSON.stringify({ + refName: status.refName, + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + workingTree: status.workingTree, + }); + }, [vcsStatus.data]); + const isActionRunning = useCallback( + (actionKey: string) => runningActions.has(actionKey), + [runningActions], + ); + + const resetWorkingTreeFileEnrichment = useCallback(() => { + workingTreeEnrichmentGenerationRef.current += 1; + if (workingTreeEnrichmentTimerRef.current !== null) { + window.clearTimeout(workingTreeEnrichmentTimerRef.current); + workingTreeEnrichmentTimerRef.current = null; + } + pendingWorkingTreeEnrichmentPathsRef.current.clear(); + inFlightWorkingTreeEnrichmentPathsRef.current.clear(); + enrichedWorkingTreeFilesRef.current = new Map(); + hiddenWorkingTreePathsRef.current = new Set(); + setEnrichedWorkingTreeFilesByPath(new Map()); + setHiddenWorkingTreePaths(new Set()); + }, []); + + const applyWorkingTreeFileEnrichmentResult = useCallback( + (result: VcsPanelWorkingTreeFileEnrichmentResult) => { + setEnrichedWorkingTreeFilesByPath((current) => { + const next = new Map(current); + for (const hiddenPath of result.hiddenPaths) { + next.delete(hiddenPath); + } + for (const file of result.files) { + next.set(file.path, file); + } + enrichedWorkingTreeFilesRef.current = next; + return next; + }); + setHiddenWorkingTreePaths((current) => { + const next = new Set(current); + for (const hiddenPath of result.hiddenPaths) { + next.add(hiddenPath); + } + hiddenWorkingTreePathsRef.current = next; + return next; + }); + }, + [], + ); + + const flushWorkingTreeFileEnrichmentQueue = useCallback(() => { + workingTreeEnrichmentTimerRef.current = null; + if (!api) return; + const paths = [...pendingWorkingTreeEnrichmentPathsRef.current].filter( + (path) => + !enrichedWorkingTreeFilesRef.current.has(path) && + !hiddenWorkingTreePathsRef.current.has(path) && + !inFlightWorkingTreeEnrichmentPathsRef.current.has(path), + ); + pendingWorkingTreeEnrichmentPathsRef.current.clear(); + if (paths.length === 0) return; + + for (const path of paths) { + inFlightWorkingTreeEnrichmentPathsRef.current.add(path); + } + const generation = workingTreeEnrichmentGenerationRef.current; + void api.vcs + .enrichWorkingTreeFiles({ cwd, paths }) + .then((result) => { + if (workingTreeEnrichmentGenerationRef.current !== generation) return; + applyWorkingTreeFileEnrichmentResult(result); + }) + .catch((nextError: unknown) => { + if (workingTreeEnrichmentGenerationRef.current === generation) { + setError(errorMessage(nextError)); + } + }) + .finally(() => { + for (const path of paths) { + inFlightWorkingTreeEnrichmentPathsRef.current.delete(path); + } + }); + }, [api, applyWorkingTreeFileEnrichmentResult, cwd]); + + const queueWorkingTreeFileEnrichment = useCallback( + (file: PanelChangedFile) => { + if (!api || !shouldEnrichWorkingTreeFile(file)) return; + const path = file.path; + if ( + enrichedWorkingTreeFilesRef.current.has(path) || + hiddenWorkingTreePathsRef.current.has(path) || + inFlightWorkingTreeEnrichmentPathsRef.current.has(path) + ) { + return; + } + pendingWorkingTreeEnrichmentPathsRef.current.add(path); + if (workingTreeEnrichmentTimerRef.current !== null) return; + workingTreeEnrichmentTimerRef.current = window.setTimeout( + flushWorkingTreeFileEnrichmentQueue, + 50, + ); + }, + [api, flushWorkingTreeFileEnrichmentQueue], + ); + + useEffect( + () => () => { + if (workingTreeEnrichmentTimerRef.current !== null) { + window.clearTimeout(workingTreeEnrichmentTimerRef.current); + } + }, + [], + ); + + const syncChangedPathSelection = useCallback((groups: readonly VcsPanelChangeGroup[]) => { + const nextChangedPaths = mergeChangeGroups(groups).map((file) => file.path); + const currentPaths = new Set(nextChangedPaths); + const previousPaths = previousChangedPathsRef.current; + setSelectedChangePaths((current) => { + const next = new Set([...current].filter((path) => currentPaths.has(path))); + for (const path of nextChangedPaths) { + if (!previousPaths.has(path)) { + next.add(path); + } + } + return next; + }); + previousChangedPathsRef.current = currentPaths; + }, []); + + const refresh = useCallback(async () => { + if (!api) return; + if (refreshInFlightRef.current) { + refreshQueuedRef.current = true; + return; + } + refreshInFlightRef.current = true; + setLoading(true); + try { + do { + refreshQueuedRef.current = false; + setError(null); + const nextSnapshot = await api.vcs.panelSnapshot({ cwd }); + resetWorkingTreeFileEnrichment(); + syncChangedPathSelection(nextSnapshot.changeGroups); + setSnapshot(nextSnapshot); + const expandedBranches = expandedBranchesForSnapshot(nextSnapshot, expandedTreeRef.current); + const nextDetails = new Map(mapBranchDetails(nextSnapshot.branchDetails)); + setLoadingBranchDetails(new Set()); + if (expandedBranches.length > 0) { + setLoadingBranchDetails(new Set(expandedBranches.map((request) => request.detailsKey))); + const details = await Promise.all( + expandedBranches.map((request) => + api.vcs.branchDetails({ + cwd, + branch: request.branch, + defaultCompareRef: nextSnapshot.defaultCompareRef, + compareBaseRef: + request.compareBaseRef ?? + compareBaseOverrides.get(request.detailsKey) ?? + compareBaseOverrides.get(request.branch.name), + }), + ), + ); + for (const [index, detail] of details.entries()) { + const request = expandedBranches[index]; + if (!request) continue; + nextDetails.set(request.detailsKey, detail); + if (request.detailsKey === request.branch.name) { + nextDetails.set(detail.fullRefName, detail); + nextDetails.set(detail.name, detail); + } + } + } + setBranchDetailsByRef(nextDetails); + } while (refreshQueuedRef.current); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + refreshInFlightRef.current = false; + setLoadingBranchDetails(new Set()); + setLoading(false); + } + }, [api, compareBaseOverrides, cwd, resetWorkingTreeFileEnrichment, syncChangedPathSelection]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (vcsStatusFingerprint === null) return; + if (lastVcsStatusFingerprintRef.current === vcsStatusFingerprint) return; + lastVcsStatusFingerprintRef.current = vcsStatusFingerprint; + void refresh(); + }, [refresh, vcsStatusFingerprint]); + + useEffect(() => { + expandedTreeRef.current = expandedTree; + }, [expandedTree]); + + useEffect(() => { + const refreshOnFocus = () => { + if (document.visibilityState === "hidden") return; + const now = Date.now(); + if (now - lastFocusRefreshAtRef.current < 1_000) return; + lastFocusRefreshAtRef.current = now; + void refresh(); + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); + return () => { + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); + }; + }, [refresh]); + + const runAction = useCallback( + async (actionKey: string, action: () => Promise) => { + setRunningActions((current) => new Set(current).add(actionKey)); + setError(null); + try { + await action(); + void invalidateSourceControlState({ environmentId, cwd }); + await refresh(); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setRunningActions((current) => { + const next = new Set(current); + next.delete(actionKey); + return next; + }); + } + }, + [cwd, environmentId, refresh], + ); + + const openFilePanel = useCallback( + (path: string) => { + useRightPanelStore.getState().openFile(threadRef, path); + }, + [threadRef], + ); + + const openInVsCode = useCallback( + async (path: string) => { + const localApi = readLocalApi(); + if (!localApi) { + setError("No local editor bridge is available."); + return; + } + try { + await openInPreferredEditor(localApi, resolvePathLinkTarget(path, cwd)); + } catch (nextError) { + setError(errorMessage(nextError)); + } + }, + [cwd], + ); + + const confirm = useCallback(async (message: string) => { + return (await readLocalApi()?.dialogs.confirm(message)) ?? window.confirm(message); + }, []); + + const copyText = useCallback((value: string, missingMessage = "Nothing to copy.") => { + if (!value) { + setError(missingMessage); + return; + } + if (typeof window === "undefined" || !navigator.clipboard?.writeText) { + setError("Clipboard API unavailable."); + return; + } + setError(null); + void navigator.clipboard + .writeText(value) + .catch((nextError) => setError(errorMessage(nextError))); + }, []); + + const openContextMenu = useCallback( + ( + event: ReactMouseEvent, + items: readonly ContextMenuItem[], + handlers: Partial Promise | void>>, + ) => { + event.preventDefault(); + event.stopPropagation(); + void (async () => { + const localApi = readLocalApi(); + if (!localApi) return; + const clicked = await localApi.contextMenu.show(items, { + x: event.clientX, + y: event.clientY, + }); + if (!clicked) return; + await handlers[clicked]?.(); + })(); + }, + [], + ); + + const openFileChangeContextMenu = useCallback( + (event: ReactMouseEvent, file: VcsPanelFileChange) => { + openContextMenu( + event, + [ + { id: "open-file", label: "Open file" }, + { id: "open-vscode", label: "Open in VS Code" }, + contextMenuSeparator("copy-separator"), + { id: "copy-filename", label: "Copy filename", icon: "copy" }, + { id: "copy-full-path", label: "Copy full path to file", icon: "copy" }, + ], + { + "open-file": () => openFilePanel(file.path), + "open-vscode": () => openInVsCode(file.path), + "copy-filename": () => copyText(fileBasename(file.path)), + "copy-full-path": () => copyText(resolvePathLinkTarget(file.path, cwd)), + }, + ); + }, + [copyText, cwd, openContextMenu, openFilePanel, openInVsCode], + ); + + const fileDiffSourceKey = useCallback((source: FileDiffSource) => { + switch (source.kind) { + case "working-tree": + return `working:${source.staged ? "staged" : "unstaged"}`; + case "commit": + return `commit:${source.sha}`; + case "compare": + return `compare:${source.baseRef}:${source.refName}`; + case "stash": + return `stash:${source.stashRef}`; + } + }, []); + + const fileDiffKey = useCallback( + (file: VcsPanelFileChange, source: FileDiffSource) => + `${fileDiffSourceKey(source)}:${file.path}:${file.originalPath ?? ""}:${file.status}`, + [fileDiffSourceKey], + ); + + const toggleFileDiff = useCallback( + (file: VcsPanelFileChange, source: FileDiffSource) => { + const key = fileDiffKey(file, source); + const expanding = !expandedFileDiffs.has(key); + setExpandedFileDiffs((current) => { + const next = new Set(current); + if (next.has(key)) { + next.delete(key); + return next; + } + next.add(key); + return next; + }); + if (!api || !expanding) return; + const existingState = fileDiffsByKey.get(key); + if (existingState?.status === "loaded") return; + setFileDiffsByKey((current) => { + const next = new Map(current); + next.set(key, { status: "loading" }); + return next; + }); + void api.vcs + .readFileDiff({ + cwd, + path: file.path, + staged: source.kind === "working-tree" ? source.staged : false, + source, + }) + .then((result) => { + setFileDiffsByKey((current) => { + const next = new Map(current); + next.set(key, { status: "loaded", patch: result.patch }); + return next; + }); + }) + .catch((nextError: unknown) => { + setFileDiffsByKey((current) => { + const next = new Map(current); + next.set(key, { status: "error", message: errorMessage(nextError) }); + return next; + }); + }); + }, + [api, cwd, expandedFileDiffs, fileDiffKey, fileDiffsByKey], + ); + + const renderFileDiff = useCallback( + (file: VcsPanelFileChange, source: FileDiffSource) => { + const state = fileDiffsByKey.get(fileDiffKey(file, source)); + if (!state || state.status === "loading") { + return
Loading diff...
; + } + if (state.status === "error") { + return
{state.message}
; + } + return ; + }, + [fileDiffKey, fileDiffsByKey, resolvedTheme], + ); + + const fileDiffListProps = useCallback( + (sourceForFile: (file: VcsPanelFileChange) => FileDiffSource) => ({ + getFileKey: (file: VcsPanelFileChange) => fileDiffKey(file, sourceForFile(file)), + isFileExpanded: (file: VcsPanelFileChange) => + expandedFileDiffs.has(fileDiffKey(file, sourceForFile(file))), + onFileToggle: (file: VcsPanelFileChange) => toggleFileDiff(file, sourceForFile(file)), + renderExpandedFile: (file: VcsPanelFileChange) => renderFileDiff(file, sourceForFile(file)), + onOpenFile: (file: VcsPanelFileChange) => openFilePanel(file.path), + onOpenInVsCode: (file: VcsPanelFileChange) => void openInVsCode(file.path), + }), + [expandedFileDiffs, fileDiffKey, openFilePanel, openInVsCode, renderFileDiff, toggleFileDiff], + ); + + const switchRef = useCallback( + (refName: string) => + runAction(`branch-switch:${refName}`, async () => { + if (!api) return; + const result = await api.vcs.switchRef({ cwd, refName }); + await onThreadRefChange?.({ branch: result.refName, worktreePath }); + }), + [api, cwd, onThreadRefChange, runAction, worktreePath], + ); + + const deleteBranch = useCallback( + (branch: VcsRef, force: boolean) => + void (async () => { + const branchLabel = branch.isRemote + ? `remote branch ${branch.name}` + : `branch ${branch.name}`; + if (!(await confirm(`Delete ${branchLabel}?`))) return; + await runAction( + `branch-delete:${branch.name}`, + () => api?.vcs.deleteBranch({ cwd, branch, force }) ?? Promise.resolve(), + ); + })(), + [api, confirm, cwd, runAction], + ); + + const undoCommit = useCallback( + (branchName: string, commit?: VcsPanelCommitSummary) => + void (async () => { + const actionKey = commitUndoActionKey(branchName, commit?.sha); + const confirmed = commit + ? await confirm( + `Undo ${commit.shortSha} and any newer commits on ${branchName}?${ + snapshot?.localBranches.find((branch) => branch.name === branchName)?.current + ? " Changes stay in the working tree." + : " This moves the branch back to that commit's parent." + }`, + ) + : await confirm(`Undo latest commit on ${branchName}?`); + if (!confirmed) return; + await runAction( + actionKey, + () => + api?.vcs.undoLatestCommit({ + cwd, + branchName, + ...(commit ? { sha: commit.sha } : {}), + }) ?? Promise.resolve(), + ); + })(), + [api, confirm, cwd, runAction, snapshot?.localBranches], + ); + + const mergeBranchIntoCurrent = useCallback( + (branchName: string) => + void (async () => { + if (!(await confirm(`Merge ${branchName} into the current branch?`))) return; + await runAction( + `branch-merge:${branchName}`, + () => api?.vcs.mergeBranchIntoCurrent({ cwd, refName: branchName }) ?? Promise.resolve(), + ); + })(), + [api, confirm, cwd, runAction], + ); + + const rebaseCurrentOnto = useCallback( + (refName: string) => + void (async () => { + if (!(await confirm(`Rebase the current branch onto ${refName}?`))) return; + await runAction( + `rebase-current:${refName}`, + () => api?.vcs.rebaseCurrentOnto({ cwd, refName }) ?? Promise.resolve(), + ); + })(), + [api, confirm, cwd, runAction], + ); + + const revertCommit = useCallback( + (commit: VcsPanelCommitSummary) => + void (async () => { + if (!(await confirm(`Revert commit ${commit.shortSha}?`))) return; + await runAction( + `commit-revert:${commit.sha}`, + () => api?.vcs.revertCommit({ cwd, sha: commit.sha }) ?? Promise.resolve(), + ); + })(), + [api, confirm, cwd, runAction], + ); + + const checkoutCommitDetached = useCallback( + (commit: VcsPanelCommitSummary) => + void (async () => { + if (!(await confirm(`Checkout ${commit.shortSha} as detached HEAD?`))) return; + await runAction(`commit-checkout:${commit.sha}`, async () => { + if (!api) return; + const result = await api.vcs.checkoutCommit({ cwd, sha: commit.sha }); + await onThreadRefChange?.({ branch: result.refName, worktreePath }); + }); + })(), + [api, confirm, cwd, onThreadRefChange, runAction, worktreePath], + ); + + const createBranchFromCommit = useCallback( + (commit: VcsPanelCommitSummary) => + void (async () => { + const branchName = window.prompt(`Create branch from ${commit.shortSha}`, ""); + const trimmed = branchName?.trim(); + if (!trimmed) return; + await runAction(`commit-create-branch:${commit.sha}`, async () => { + await api?.vcs.createBranchFromCommit({ + cwd, + sha: commit.sha, + branchName: trimmed, + }); + }); + })(), + [api, cwd, runAction], + ); + + const publishBranch = useCallback( + (branch: VcsRef, remoteName?: string, force = false) => + runAction(`branch-sync:${branch.name}`, async () => { + await api?.vcs.pushBranch({ cwd, branchName: branch.name, remoteName, force }); + }), + [api, cwd, runAction], + ); + + const publishBranchWithRemoteChoice = useCallback( + (branch: VcsRef, force = false) => { + if (!snapshot || branchHasUpstream(branch, snapshot)) { + void publishBranch(branch, undefined, force); + return; + } + if (snapshot.remotes.length > 1) { + setPublishRemoteTarget({ branch, force }); + return; + } + void publishBranch(branch, snapshot.remotes[0]?.name, force); + }, + [publishBranch, snapshot], + ); + + const runBranchSync = useCallback( + ( + branch: VcsRef, + { + fetchFirst = false, + force = false, + }: { + readonly fetchFirst?: boolean; + readonly force?: boolean; + } = {}, + ) => { + if (!snapshot) return; + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + const state = branchSyncState(branch, snapshot); + if (state === "diverged") { + setDivergedSyncBranch(branch); + return; + } + if (state === "publish") { + publishBranchWithRemoteChoice(branch, force); + return; + } + if (!branch.current) { + const actionKey = + state === "fetch" ? `branch-fetch:${branch.name}` : `branch-sync:${branch.name}`; + void runAction(actionKey, async () => { + if (!api) return; + if (state === "push") { + await api.vcs.pushBranch({ cwd, branchName: branch.name, force }); + return; + } + if (state === "pull") { + await api.vcs.pullBranch({ + cwd, + branchName: branch.name, + force, + }); + return; + } + await api.vcs.fetchBranch({ cwd, branchName: branch.name }); + }); + return; + } + void runAction(`branch-sync:${branch.name}`, async () => { + if (!api) return; + if (fetchFirst) { + await api.vcs.fetchBranch({ cwd, branchName: branch.name }); + } + if (aheadCount > 0) { + await api.vcs.pushBranch({ cwd, branchName: branch.name }); + return; + } + if (behindCount > 0) { + await api.vcs.pullBranch({ + cwd, + branchName: branch.name, + force, + }); + return; + } + await api.vcs.fetchBranch({ cwd, branchName: branch.name }); + }); + }, + [api, cwd, publishBranchWithRemoteChoice, runAction, snapshot], + ); + + const syncBranch = useCallback( + (branch: VcsRef, event: ReactMouseEvent) => + runBranchSync(branch, { + fetchFirst: shouldFetchBeforePull(event), + force: isActionForced(event), + }), + [runBranchSync], + ); + + const runDivergedSync = useCallback( + (mode: "force-pull" | "merge" | "force-push") => { + const branch = divergedSyncBranch; + setDivergedSyncBranch(null); + if (!branch) return; + void runAction(`branch-sync:${branch.name}`, async () => { + if (!api) return; + if (mode === "force-push") { + await api.vcs.pushBranch({ cwd, branchName: branch.name, force: true }); + return; + } + if (mode === "force-pull") { + await api.vcs.pullBranch({ cwd, branchName: branch.name, force: true }); + return; + } + await api.vcs.pullBranch({ cwd, branchName: branch.name, merge: true }); + await api.vcs.pushBranch({ cwd, branchName: branch.name }); + }); + }, + [api, cwd, divergedSyncBranch, runAction], + ); + + const fetchActionableBranches = useCallback( + () => runAction("work-fetch", () => api?.vcs.fetchAllRemotes({ cwd }) ?? Promise.resolve()), + [api, cwd, runAction], + ); + + useEffect(() => { + if (!api) return; + const interval = window.setInterval( + () => { + if (runningActions.has("work-fetch")) return; + void fetchActionableBranches(); + }, + 5 * 60 * 1_000, + ); + return () => window.clearInterval(interval); + }, [api, fetchActionableBranches, runningActions]); + + const runPanelCommit = useCallback( + (message: string) => { + const commitMessage = message.trim(); + return runAction("changes-commit", async () => { + setCommitDialogOpen(false); + setDialogCommitMessage(""); + await gitAction.run({ + actionId: newCommandId(), + action: "commit", + ...(commitMessage ? { commitMessage } : {}), + filePaths: [...selectedChangePathList], + }); + }); + }, + [gitAction, runAction, selectedChangePathList], + ); + + const runGeneratedPanelCommit = useCallback(() => { + return runPanelCommit(""); + }, [runPanelCommit]); + + const openCommitDialog = useCallback(() => { + setDialogCommitMessage(""); + setCommitDialogOpen(true); + }, []); + + const createStash = useCallback( + (paths: readonly string[], message?: string) => { + const stashMessage = message?.trim(); + return runAction("changes-stash", async () => { + if (!api) return; + await api.vcs.createStash({ + cwd, + mode: "all", + includeUntracked: true, + paths: [...paths], + ...(stashMessage ? { message: stashMessage } : {}), + }); + }); + }, + [api, cwd, runAction], + ); + + const runGeneratedPanelStash = useCallback(() => { + return createStash(selectedChangePathList); + }, [createStash, selectedChangePathList]); + + const openStashDialog = useCallback((label: string, paths: readonly string[]) => { + setStashDialogTarget({ label, paths }); + setDialogStashMessage(""); + }, []); + + const runPanelStash = useCallback(() => { + if (!stashDialogTarget) return; + const paths = stashDialogTarget.paths; + const message = dialogStashMessage.trim(); + setStashDialogTarget(null); + setDialogStashMessage(""); + void createStash(paths, message); + }, [createStash, dialogStashMessage, stashDialogTarget]); + + const discardSelectedChanges = useCallback( + () => + void (async () => { + if (selectedChangedFiles.length === 0) return; + const countLabel = + selectedChangedFiles.length === 1 + ? "the selected change" + : `${selectedChangedFiles.length} selected changes`; + if (!(await confirm(`Discard ${countLabel}?`))) return; + const stagedPaths = uniquePaths( + selectedChangedFiles + .filter((file) => file.hasStagedChanges) + .flatMap((file) => operationPathsForFile(file)), + ); + const unstagedPaths = uniquePaths( + selectedChangedFiles + .filter((file) => file.hasUnstagedChanges) + .flatMap((file) => operationPathsForFile(file)), + ); + await runAction("changes-discard-selected", async () => { + if (!api) return; + if (unstagedPaths.length > 0) { + await api.vcs.discardFiles({ cwd, paths: unstagedPaths, staged: false }); + } + if (stagedPaths.length > 0) { + await api.vcs.discardFiles({ cwd, paths: stagedPaths, staged: true }); + } + }); + })(), + [api, confirm, cwd, runAction, selectedChangedFiles], + ); + + const toggleSection = useCallback((key: SectionKey) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const isTreeExpanded = useCallback( + (key: string, defaultExpanded = false) => + defaultExpanded ? !collapsedDefaultTree.has(key) : expandedTree.has(key), + [collapsedDefaultTree, expandedTree], + ); + + const toggleTree = useCallback((key: string, defaultExpanded = false) => { + if (defaultExpanded) { + setCollapsedDefaultTree((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + return; + } + setExpandedTree((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const loadBranchDetails = useCallback( + async (branch: VcsRef, compareBaseRef?: string, detailsKey = branch.name) => { + if (!api || !snapshot) return; + if (!compareBaseRef && branchDetailsByRef.has(detailsKey)) return; + setLoadingBranchDetails((current) => { + const next = new Set(current); + next.add(detailsKey); + return next; + }); + try { + const details = await api.vcs.branchDetails({ + cwd, + branch, + defaultCompareRef: snapshot.defaultCompareRef, + compareBaseRef: + compareBaseRef ?? + compareBaseOverrides.get(detailsKey) ?? + compareBaseOverrides.get(branch.name), + }); + setBranchDetailsByRef((current) => { + const next = new Map(current); + next.set(detailsKey, details); + if (detailsKey === branch.name) { + next.set(details.fullRefName, details); + next.set(details.name, details); + } + return next; + }); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setLoadingBranchDetails((current) => { + const next = new Set(current); + next.delete(detailsKey); + return next; + }); + } + }, + [api, branchDetailsByRef, compareBaseOverrides, cwd, snapshot], + ); + + const chooseCompareBase = useCallback( + (baseRef: string) => { + const target = compareBaseDialogTarget; + setCompareBaseDialogTarget(null); + setCompareBaseQuery(""); + if (!target) return; + setCompareBaseOverrides((current) => { + const next = new Map(current); + next.set(target.detailsKey, baseRef); + return next; + }); + void loadBranchDetails(target.branch, baseRef, target.detailsKey); + }, + [compareBaseDialogTarget, loadBranchDetails], + ); + + const toggleBranchTree = useCallback( + (key: string, branch: VcsRef, compareBaseRef?: string, detailsKey = branch.name) => { + const expanding = !expandedTree.has(key); + toggleTree(key); + if (expanding) void loadBranchDetails(branch, compareBaseRef, detailsKey); + }, + [expandedTree, loadBranchDetails, toggleTree], + ); + + const toggleBranchTreeFromKeyboard = useCallback( + ( + key: string, + branch: VcsRef, + event: ReactKeyboardEvent, + compareBaseRef?: string, + detailsKey = branch.name, + ) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleBranchTree(key, branch, compareBaseRef, detailsKey); + }, + [toggleBranchTree], + ); + + const loadMoreBranchCommits = useCallback( + async (branch: VcsRef, details: VcsPanelBranchDetails, kind: BranchCommitListKind) => { + const loadedCount = + kind === "ahead" + ? details.aheadCommits.length + : kind === "behind" + ? details.behindCommits.length + : kind === "compare-history" + ? details.compareCommits.length + : details.commits.length; + const remaining = + kind === "ahead" + ? details.aheadCommitsRemaining + : kind === "behind" + ? details.behindCommitsRemaining + : kind === "compare-history" + ? details.compareCommitsRemaining + : details.commitsRemaining; + if (!api || remaining <= 0) return; + setLoadingBranchDetails((current) => { + const next = new Set(current); + next.add(branch.name); + return next; + }); + try { + const result = await api.vcs.branchCommits({ + cwd, + branch, + baseRef: details.baseRef, + kind, + skip: loadedCount, + limit: COMMIT_PAGE_SIZE, + }); + setBranchDetailsByRef((current) => { + const nextDetails = current.get(details.fullRefName) ?? details; + const merged = + kind === "ahead" + ? { + ...nextDetails, + aheadCommits: [...nextDetails.aheadCommits, ...result.commits], + aheadCommitsRemaining: result.remaining, + } + : kind === "behind" + ? { + ...nextDetails, + behindCommits: [...nextDetails.behindCommits, ...result.commits], + behindCommitsRemaining: result.remaining, + } + : kind === "compare-history" + ? { + ...nextDetails, + compareCommits: [...nextDetails.compareCommits, ...result.commits], + compareCommitsRemaining: result.remaining, + } + : { + ...nextDetails, + commits: [...nextDetails.commits, ...result.commits], + commitsRemaining: result.remaining, + }; + const next = new Map(current); + next.set(merged.fullRefName, merged); + next.set(merged.name, merged); + return next; + }); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setLoadingBranchDetails((current) => { + const next = new Set(current); + next.delete(branch.name); + return next; + }); + } + }, + [api, cwd], + ); + + const loadStashDetails = useCallback( + async (stashRef: string) => { + if (!api || stashDetailsByRef.has(stashRef)) return; + setLoadingStashDetails((current) => { + const next = new Set(current); + next.add(stashRef); + return next; + }); + try { + const details = await api.vcs.stashDetails({ cwd, stashRef }); + setStashDetailsByRef((current) => { + const next = new Map(current); + next.set(details.refName, details); + return next; + }); + } catch (nextError) { + setError(errorMessage(nextError)); + } finally { + setLoadingStashDetails((current) => { + const next = new Set(current); + next.delete(stashRef); + return next; + }); + } + }, + [api, cwd, stashDetailsByRef], + ); + + const toggleStashTree = useCallback( + (key: string, stashRef: string) => { + const expanding = !expandedTree.has(key); + toggleTree(key); + if (expanding) void loadStashDetails(stashRef); + }, + [expandedTree, loadStashDetails, toggleTree], + ); + + const toggleTreeFromKeyboard = useCallback( + (key: string, event: ReactKeyboardEvent, defaultExpanded = false) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleTree(key, defaultExpanded); + }, + [toggleTree], + ); + + const startSectionResize = useCallback( + (key: SectionKey, event: ReactMouseEvent) => { + event.preventDefault(); + const openKeys = SECTION_ORDER.filter((sectionKey) => !collapsed.has(sectionKey)); + const index = openKeys.indexOf(key); + if (index < 0 || openKeys.length < 2) return; + const adjacentKey = openKeys[index + 1] ?? openKeys[index - 1]; + if (!adjacentKey) return; + const direction = openKeys[index + 1] ? 1 : -1; + const startY = event.clientY; + const startCurrent = sectionWeights[key]; + const startAdjacent = sectionWeights[adjacentKey]; + const total = startCurrent + startAdjacent; + const containerHeight = Math.max(containerRef.current?.clientHeight ?? 1, 1); + const onMove = (moveEvent: MouseEvent) => { + const deltaWeight = ((moveEvent.clientY - startY) / containerHeight) * total * direction; + const nextCurrent = Math.min( + total - MIN_SECTION_WEIGHT, + Math.max(MIN_SECTION_WEIGHT, startCurrent + deltaWeight), + ); + setSectionWeights((current) => ({ + ...current, + [key]: nextCurrent, + [adjacentKey]: total - nextCurrent, + })); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [collapsed, sectionWeights], + ); + + const section = (key: SectionKey, children: ReactNode, action?: ReactNode) => ( + toggleSection(key)} + onResizeStart={startSectionResize} + action={action} + > + {children} + + ); + + if (loading && !snapshot) { + return ( +
+ Loading repository state... +
+ ); + } + + if (!snapshot) { + return ( +
+
+
+ {error ?? "Source control is unavailable."} +
+
+ copyText(error ?? "", "No error to copy.")} + > + + +
+
+ +
+ ); + } + + const toggleChangedFileSelection = (path: string, checked: boolean) => { + setSelectedChangePaths((current) => { + const next = new Set(current); + if (checked) next.add(path); + else next.delete(path); + return next; + }); + }; + + const renderWorkingFile = (file: PanelChangedFile) => { + const selected = selectedChangePaths.has(file.path); + const discardKey = `file-discard:${file.path}`; + const diffSource = { + kind: "working-tree", + staged: file.hasStagedChanges, + } satisfies FileDiffSource; + const diffExpanded = expandedFileDiffs.has(fileDiffKey(file, diffSource)); + const discardFile = () => + void (async () => { + if (!(await confirm(`Discard changes in ${file.path}?`))) return; + await runAction(discardKey, async () => { + if (!api) return; + const paths = operationPathsForFile(file); + if (file.hasUnstagedChanges) { + await api.vcs.discardFiles({ cwd, paths, staged: false }); + } + if (file.hasStagedChanges) { + await api.vcs.discardFiles({ cwd, paths, staged: true }); + } + }); + })(); + return ( +
+
toggleFileDiff(file, diffSource)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleFileDiff(file, diffSource); + }} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "open-file", label: "Open file" }, + { id: "open-vscode", label: "Open in VS Code" }, + contextMenuSeparator("discard-separator"), + { + id: "discard", + label: "Discard change", + destructive: true, + disabled: isActionRunning(discardKey), + icon: "trash", + }, + contextMenuSeparator("copy-separator"), + { id: "copy-filename", label: "Copy filename", icon: "copy" }, + { id: "copy-full-path", label: "Copy full path to file", icon: "copy" }, + ], + { + discard: discardFile, + "open-file": () => openFilePanel(file.path), + "open-vscode": () => openInVsCode(file.path), + "copy-filename": () => copyText(fileBasename(file.path)), + "copy-full-path": () => copyText(resolvePathLinkTarget(file.path, cwd)), + }, + ) + } + > + {diffExpanded ? ( + + ) : ( + + )} + event.stopPropagation()}> + toggleChangedFileSelection(file.path, checked === true)} + /> + + + {fileStatusLetter(file.status)} + + {file.path} + + + + + + openFilePanel(file.path)}> + + + void openInVsCode(file.path)}> + + + +
+ {diffExpanded ? ( +
+ {renderFileDiff(file, diffSource)} +
+ ) : null} +
+ ); + }; + + const renderWorkingFileItem = ({ item }: { item: PanelChangedFile }) => ( + + ); + + const renderCommit = ( + commit: VcsPanelCommitSummary, + options: { readonly undoBranchName?: string } = {}, + ) => { + const key = treeKey("commit", commit.sha); + const expanded = expandedTree.has(key); + const stats = sumFiles(commit.files); + const relativeDate = formatRelativeDate(commit.authoredAt); + const undoKey = options.undoBranchName + ? commitUndoActionKey(options.undoBranchName, commit.sha) + : null; + const revertKey = `commit-revert:${commit.sha}`; + const rebaseKey = `rebase-current:${commit.sha}`; + const checkoutKey = `commit-checkout:${commit.sha}`; + const createBranchKey = `commit-create-branch:${commit.sha}`; + const canUndoCommit = options.undoBranchName !== undefined; + return ( +
+ + toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + ...(canUndoCommit + ? [ + { + id: "undo", + label: "Undo", + disabled: undoKey ? isActionRunning(undoKey) : false, + }, + ] + : []), + { id: "revert", label: "Revert commit" }, + { id: "rebase", label: "Rebase current branch onto commit" }, + { id: "checkout", label: "Checkout as detached HEAD" }, + { id: "create-branch", label: "Create branch from commit" }, + contextMenuSeparator("copy-separator"), + { id: "copy-sha", label: "Copy SHA", icon: "copy" }, + { id: "copy-message", label: "Copy message", icon: "copy" }, + ], + { + undo: () => { + if (options.undoBranchName) undoCommit(options.undoBranchName, commit); + }, + revert: () => revertCommit(commit), + rebase: () => rebaseCurrentOnto(commit.sha), + checkout: () => checkoutCommitDetached(commit), + "create-branch": () => createBranchFromCommit(commit), + "copy-sha": () => copyText(commit.sha), + "copy-message": () => copyText(commit.message), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + {commit.message} + + + {relativeDate ? ( + {relativeDate} + ) : null} + + {canUndoCommit && options.undoBranchName ? ( + { + if (options.undoBranchName) undoCommit(options.undoBranchName, commit); + }} + > + + + ) : null} + revertCommit(commit)} + > + + + rebaseCurrentOnto(commit.sha)} + > + + + checkoutCommitDetached(commit)} + > + + + createBranchFromCommit(commit)} + > + + + +
+ } + /> + + + + + {expanded ? ( +
+ ({ kind: "commit", sha: commit.sha }))} + /> +
+ ) : null} + + ); + }; + + const renderBranchSubsection = ({ + details, + id, + title, + count, + children, + icon, + action, + defaultExpanded, + }: { + readonly details: VcsPanelBranchDetails; + readonly id: string; + readonly title: ReactNode; + readonly count: ReactNode | null; + readonly children: ReactNode; + readonly icon?: ReactNode; + readonly action?: ReactNode; + readonly defaultExpanded?: boolean; + }) => { + const key = treeKey("branch-subsection", `${details.fullRefName}:${id}`); + const expanded = isTreeExpanded(key, defaultExpanded); + return ( +
+
toggleTree(key, defaultExpanded)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event, defaultExpanded)} + > + {expanded ? ( + + ) : ( + + )} + {icon} + {title} + {action} + {count !== null ? {count} : null} +
+ {expanded ?
{children}
: null} +
+ ); + }; + + const renderBranchTree = (branch: VcsRef, details: VcsPanelBranchDetails, detailsKey: string) => { + const unsyncedCommitShas = new Set(details.unsyncedCommitShas); + const loadingDetails = loadingBranchDetails.has(branch.name); + const aheadTotal = details.aheadCommits.length + details.aheadCommitsRemaining; + const behindTotal = details.behindCommits.length + details.behindCommitsRemaining; + const historyTotal = details.commits.length + details.commitsRemaining; + const openCompareBaseDialog = () => { + setCompareBaseDialogTarget({ branch, detailsKey }); + setCompareBaseQuery(""); + }; + const renderBranchCommit = (commit: VcsPanelCommitSummary) => + renderCommit( + commit, + unsyncedCommitShas.has(commit.sha) ? { undoBranchName: branch.name } : undefined, + ); + return ( +
+ + {aheadTotal > 0 + ? renderBranchSubsection({ + details, + id: "ahead", + title: `${aheadTotal} Ahead`, + count: null, + icon: , + children: ( +
+ {details.aheadCommits.map(renderBranchCommit)} + void loadMoreBranchCommits(branch, details, "ahead")} + /> +
+ ), + }) + : null} + {behindTotal > 0 + ? renderBranchSubsection({ + details, + id: "behind", + title: `${behindTotal} Behind`, + count: null, + icon: , + children: ( +
+ {details.behindCommits.map(renderBranchCommit)} + void loadMoreBranchCommits(branch, details, "behind")} + /> +
+ ), + }) + : null} + {renderBranchSubsection({ + details, + id: "commits", + title: "History", + count: commitCountLabel(historyTotal), + children: ( +
+ {details.commits.length === 0 ? ( +
No commits.
+ ) : ( + details.commits.map(renderBranchCommit) + )} + void loadMoreBranchCommits(branch, details, "history")} + /> +
+ ), + })} + {details.baseRef + ? renderBranchSubsection({ + details, + id: "compare-changes", + title: "Changes", + count: null, + action: , + children: ( + ({ + kind: "compare", + baseRef: details.baseRef!, + refName: details.name, + }))} + /> + ), + }) + : null} +
+ ); + }; + + const branchRow = ( + branch: VcsRef, + options: { + readonly key?: string; + readonly detailsKey?: string; + readonly compareBaseRef?: string; + readonly syncCounts?: { readonly aheadCount: number; readonly behindCount: number }; + readonly attention?: AttentionKind; + readonly syncLabel?: string; + readonly syncState?: BranchSyncState; + readonly syncActionKey?: string; + readonly fetchActionKey?: string; + readonly onSync?: (event?: ReactMouseEvent) => void; + readonly secondaryBadge?: ReactNode; + } = {}, + ) => { + const detailsKey = options.detailsKey ?? branch.name; + const details = branchDetailsByRef.get(detailsKey); + const key = options.key ?? treeKey("branch", branch.name); + const expanded = expandedTree.has(key); + const loadingDetails = loadingBranchDetails.has(detailsKey); + const current = branch.current; + const { aheadCount, behindCount } = options.syncCounts ?? branchSyncCounts(branch, snapshot); + const hasUpstream = branchHasUpstream(branch, snapshot); + const attention = options.attention ?? branchAttention(branch, snapshot); + const syncState = options.syncState ?? branchSyncState(branch, snapshot); + const switchKey = `branch-switch:${branch.name}`; + const syncKey = options.syncActionKey ?? `branch-sync:${branch.name}`; + const fetchKey = options.fetchActionKey ?? `branch-fetch:${branch.name}`; + const deleteKey = `branch-delete:${branch.name}`; + const undoKey = `branch-undo-latest:${branch.name}`; + const mergeKey = `branch-merge:${branch.name}`; + const rebaseKey = `rebase-current:${branch.name}`; + const syncLabel = options.syncLabel ?? branchSyncActionLabel(syncState); + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const switchDisabled = current || isActionRunning(switchKey); + const syncDisabled = isActionRunning(syncKey) || isActionRunning(fetchKey); + const deleteDisabled = current || isActionRunning(deleteKey); + const runSync = (event?: ReactMouseEvent) => + options.onSync + ? options.onSync(event) + : event + ? syncBranch(branch, event) + : runBranchSync(branch); + return ( +
+
toggleBranchTree(key, branch, options.compareBaseRef, detailsKey)} + onKeyDown={(event) => + toggleBranchTreeFromKeyboard(key, branch, event, options.compareBaseRef, detailsKey) + } + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "switch", label: "Checkout", disabled: switchDisabled }, + { id: "sync", label: syncLabel, disabled: syncDisabled }, + ...(current && aheadCount > 0 + ? [ + { + id: "undo-latest", + label: "Undo latest commit", + disabled: isActionRunning(undoKey), + }, + ] + : []), + ...(!current + ? [ + { + id: "merge", + label: "Merge branch into current", + disabled: isActionRunning(mergeKey), + }, + { + id: "rebase", + label: "Rebase current branch onto branch", + disabled: isActionRunning(rebaseKey), + }, + ] + : []), + contextMenuSeparator("delete-separator-before"), + { + id: "delete", + label: "Delete branch", + destructive: true, + disabled: deleteDisabled, + icon: "trash", + }, + contextMenuSeparator("delete-separator-after"), + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ], + { + switch: () => switchRef(branch.name), + sync: () => runSync(), + delete: () => deleteBranch(branch, false), + "undo-latest": () => undoCommit(branch.name), + merge: () => mergeBranchIntoCurrent(branch.name), + rebase: () => rebaseCurrentOnto(branch.name), + "copy-branch-name": () => copyText(branch.name), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + {branch.name} +
+ {hasUpstream && aheadCount === 0 && behindCount === 0 ? ( + + + + ) : null} + {!hasUpstream ? local : null} + {options.secondaryBadge} + {current ? current : null} + {branch.isDefault ? default : null} + {branch.worktreePath && !current ? worktree : null} + + {relativeDate ? ( + {relativeDate} + ) : null} +
+ + void switchRef(branch.name)} + > + + + runSync(event)} + > + + + deleteBranch(branch, isActionForced(event))} + > + + + {current && aheadCount > 0 ? ( + undoCommit(branch.name)} + > + + + ) : null} + {!current ? ( + <> + mergeBranchIntoCurrent(branch.name)} + > + + + rebaseCurrentOnto(branch.name)} + > + + + + ) : null} + +
+ {expanded && details ? renderBranchTree(branch, details, detailsKey) : null} + {expanded && !details && loadingDetails ? ( +
+ Loading... +
+ ) : null} +
+ ); + }; + + const remoteBranchRow = (branch: VcsRef, displayName: string, hasLocalBranch: boolean) => { + const details = branchDetailsByRef.get(branch.name); + const key = treeKey("remote-branch", `${branch.remoteName ?? "local"}:${displayName}`); + const expanded = expandedTree.has(key); + const loadingDetails = loadingBranchDetails.has(branch.name); + const current = branch.current; + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + const hasUpstream = branchHasUpstream(branch, snapshot); + const syncState = branchSyncState(branch, snapshot); + const switchKey = `branch-switch:${branch.name}`; + const syncKey = `branch-sync:${branch.name}`; + const fetchKey = `branch-fetch:${branch.name}`; + const deleteKey = `branch-delete:${branch.name}`; + const undoKey = `branch-undo-latest:${branch.name}`; + const mergeKey = `branch-merge:${branch.name}`; + const rebaseKey = `rebase-current:${branch.name}`; + const switchDisabled = current || isActionRunning(switchKey); + const syncLabel = hasLocalBranch ? branchSyncActionLabel(syncState) : "Fetch branch"; + const syncDisabled = hasLocalBranch + ? isActionRunning(syncKey) || isActionRunning(fetchKey) + : isActionRunning(fetchKey); + const deleteDisabled = isActionRunning(deleteKey); + const fetchRemoteBranch = () => + void runAction( + fetchKey, + () => api?.vcs.fetchBranch({ cwd, branchName: branch.name }) ?? Promise.resolve(), + ); + return ( +
+
toggleBranchTree(key, branch)} + onKeyDown={(event) => toggleBranchTreeFromKeyboard(key, branch, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "switch", label: "Checkout", disabled: switchDisabled }, + { id: "sync", label: syncLabel, disabled: syncDisabled }, + ...(current && aheadCount > 0 + ? [ + { + id: "undo-latest", + label: "Undo latest commit", + disabled: isActionRunning(undoKey), + }, + ] + : []), + ...(!current + ? [ + { + id: "merge", + label: "Merge branch into current", + disabled: isActionRunning(mergeKey), + }, + { + id: "rebase", + label: "Rebase current branch onto branch", + disabled: isActionRunning(rebaseKey), + }, + ] + : []), + contextMenuSeparator("delete-separator-before"), + { + id: "delete", + label: hasLocalBranch ? "Delete branch" : "Delete remote branch", + destructive: true, + disabled: deleteDisabled, + icon: "trash", + }, + contextMenuSeparator("delete-separator-after"), + { id: "copy-branch-name", label: "Copy branch name", icon: "copy" }, + ], + { + switch: () => switchRef(branch.name), + sync: () => (hasLocalBranch ? runBranchSync(branch) : fetchRemoteBranch()), + delete: () => deleteBranch(branch, false), + "undo-latest": () => undoCommit(branch.name), + merge: () => mergeBranchIntoCurrent(branch.name), + rebase: () => rebaseCurrentOnto(branch.name), + "copy-branch-name": () => copyText(displayName), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + {hasLocalBranch ? ( + + ) : ( + + )} + {displayName} +
+ {hasLocalBranch && !hasUpstream ? local : null} + {current ? current : null} + {branch.isDefault ? default : null} + + {relativeDate ? ( + {relativeDate} + ) : null} +
+ + void switchRef(branch.name)} + > + + + {hasLocalBranch ? ( + syncBranch(branch, event)} + > + + + ) : ( + + + + )} + deleteBranch(branch, hasLocalBranch && isActionForced(event))} + > + + + {current && aheadCount > 0 ? ( + undoCommit(branch.name)} + > + + + ) : null} + {!current ? ( + <> + mergeBranchIntoCurrent(branch.name)} + > + + + rebaseCurrentOnto(branch.name)} + > + + + + ) : null} + +
+ {expanded && details ? renderBranchTree(branch, details, branch.name) : null} + {expanded && !details && loadingDetails ? ( +
+ Loading... +
+ ) : null} +
+ ); + }; + + const remoteRow = (remote: VcsPanelRemote) => { + const key = treeKey("remote", remote.name); + const expanded = expandedTree.has(key); + const fetchKey = `remote-fetch:${remote.name}`; + const removeKey = `remote-remove:${remote.name}`; + const fetchRemote = () => + void runAction( + fetchKey, + () => api?.vcs.fetchRemote({ cwd, remoteName: remote.name }) ?? Promise.resolve(), + ); + const removeRemote = () => + void (async () => { + if (!(await confirm(`Remove remote ${remote.name}?`))) return; + await runAction( + removeKey, + () => api?.vcs.removeRemote({ cwd, remoteName: remote.name }) ?? Promise.resolve(), + ); + })(); + const remoteUrl = remote.fetchUrl ?? remote.pushUrl ?? ""; + return ( +
+
toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "fetch", label: "Fetch remote", disabled: isActionRunning(fetchKey) }, + contextMenuSeparator("remove-separator-before"), + { + id: "remove", + label: "Remove remote", + destructive: true, + disabled: isActionRunning(removeKey), + icon: "trash", + }, + contextMenuSeparator("remove-separator-after"), + { id: "copy-name", label: "Copy name", icon: "copy" }, + { id: "copy-url", label: "Copy url", disabled: !remoteUrl, icon: "copy" }, + ], + { + fetch: fetchRemote, + remove: removeRemote, + "copy-name": () => copyText(remote.name), + "copy-url": () => copyText(remoteUrl, "Remote URL unavailable."), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + {remote.name} + + {remote.fetchUrl ?? "No fetch URL"} + + + + + + + + + +
+ {expanded ? ( +
+ {remote.branches.length === 0 ? ( +
No remote branches.
+ ) : ( + remote.branches.map((branch) => { + const localBranch = localBranchForRemoteBranch(snapshot, remote, branch); + return remoteBranchRow( + localBranch ?? remoteBranchRef(remote, branch), + branch.name, + localBranch !== null, + ); + }) + )} +
+ ) : null} +
+ ); + }; + + const localBranchesRow = (branches: readonly VcsRef[]) => { + const key = treeKey("remote", "local"); + const expanded = expandedTree.has(key); + return ( +
+
toggleTree(key)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event)} + onContextMenu={(event) => + openContextMenu( + event, + [ + contextMenuSeparator("copy-separator"), + { id: "copy-name", label: "Copy name", icon: "copy" }, + ], + { + "copy-name": () => copyText("unpublished"), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + unpublished + + {branches.length === 1 ? "1 branch" : `${branches.length} branches`} + +
+ {expanded ? ( +
+ {branches.map((branch) => remoteBranchRow(branch, branch.name, true))} +
+ ) : null} +
+ ); + }; + + const stashRow = (stash: VcsPanelStash) => { + const key = treeKey("stash", stash.refName); + const expanded = expandedTree.has(key); + const details = stashDetailsByRef.get(stash.refName); + const loadingDetails = loadingStashDetails.has(stash.refName); + const applyKey = `stash-apply:${stash.refName}`; + const popKey = `stash-pop:${stash.refName}`; + const dropKey = `stash-drop:${stash.refName}`; + const relativeDate = formatRelativeDate(stash.createdAt); + const branchName = stashBranchName(stash); + const applyStash = () => + void runAction( + applyKey, + () => api?.vcs.applyStash({ cwd, stashRef: stash.refName }) ?? Promise.resolve(), + ); + const popStash = () => + void runAction( + popKey, + () => api?.vcs.popStash({ cwd, stashRef: stash.refName }) ?? Promise.resolve(), + ); + const dropStash = () => + void (async () => { + if (!(await confirm(`Drop ${stash.refName}?`))) return; + await runAction( + dropKey, + () => api?.vcs.dropStash({ cwd, stashRef: stash.refName }) ?? Promise.resolve(), + ); + })(); + return ( +
+
toggleStashTree(key, stash.refName)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleStashTree(key, stash.refName); + }} + onContextMenu={(event) => + openContextMenu( + event, + [ + { id: "apply", label: "Apply stash", disabled: isActionRunning(applyKey) }, + { id: "pop", label: "Pop stash", disabled: isActionRunning(popKey) }, + contextMenuSeparator("drop-separator-before"), + { + id: "drop", + label: "Drop stash", + destructive: true, + disabled: isActionRunning(dropKey), + icon: "trash", + }, + contextMenuSeparator("drop-separator-after"), + { id: "copy-stash-name", label: "Copy stash name", icon: "copy" }, + { + id: "copy-branch-name", + label: "Copy branch name", + disabled: !branchName, + icon: "copy", + }, + ], + { + apply: applyStash, + pop: popStash, + drop: dropStash, + "copy-stash-name": () => copyText(stash.refName), + "copy-branch-name": () => copyText(branchName ?? "", "Stash branch unavailable."), + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + {stash.message} + {relativeDate ? ( + {relativeDate} + ) : null} + {stash.refName} + + + + + + + + + + + +
+ {expanded && details ? ( +
+ ({ kind: "stash", stashRef: stash.refName }))} + /> +
+ ) : null} + {expanded && !details && loadingDetails ? ( +
+ Loading... +
+ ) : null} +
+ ); + }; + + const repositorySummary = ( +
+
+ + {snapshot.status.refName ?? "Detached HEAD"} + + +
+
+ + {changedFiles.length > 0 + ? changedFiles.length === 1 + ? "1 file" + : `${changedFiles.length} files` + : "Clean"} + + + {snapshot.status.aheadOfDefaultCount ? ( + {snapshot.status.aheadOfDefaultCount} ahead of default + ) : null} +
+ {error ? ( +
+
{error}
+
+ copyText(error)}> + + +
+
+ ) : null} +
+ ); + + const changesSection = ( +
+ {changedFiles.length === 0 ? ( +
Working tree clean
+ ) : ( + <> +
+
+ + + + + } + /> + + {allChangedFilesSelected ? "Unselect all files" : "Select all files"} + + + + {selectedChangedFiles.length} of {changedFiles.length} files selected + + +
+
+ + event.shiftKey ? openCommitDialog() : void runGeneratedPanelCommit() + } + > + + + + event.shiftKey + ? openStashDialog("selected", selectedChangePathList) + : void runGeneratedPanelStash() + } + > + + + + + +
+
+
+ + data={changedFiles} + keyExtractor={(file) => file.path} + renderItem={renderWorkingFileItem} + estimatedItemSize={WORKING_FILE_ROW_ESTIMATED_HEIGHT} + drawDistance={WORKING_FILE_DRAW_DISTANCE} + extraData={workingFileListExtraData} + className="scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain" + /> +
+ + )} +
+ ); + + type WorkItem = + | { + readonly kind: "working-tree"; + readonly key: string; + readonly attention: AttentionKind; + readonly activity: number; + } + | { + readonly kind: "branch"; + readonly key: string; + readonly branch: VcsRef; + readonly attention: AttentionKind; + readonly activity: number; + } + | { + readonly kind: "fork-branch"; + readonly key: string; + readonly branch: VcsRef; + readonly fork: VcsPanelSnapshotResult["actionableForkBranches"][number]; + readonly attention: AttentionKind; + readonly activity: number; + } + | { + readonly kind: "stash"; + readonly key: string; + readonly stash: VcsPanelStash; + readonly attention: AttentionKind; + readonly activity: number; + }; + + const currentBranch = snapshot.localBranches.find((branch) => branch.current) ?? null; + const localBranchesWithoutUpstream = localOnlyBranches(snapshot); + const workingTreeAttention: AttentionKind = changedFiles.some((file) => file.hasConflicts) + ? "conflicts" + : changedFiles.length > 0 + ? "dirty" + : "stale"; + const workItems: WorkItem[] = [ + ...(changedFiles.length > 0 + ? [ + { + kind: "working-tree" as const, + key: "working-tree", + attention: workingTreeAttention, + activity: currentBranch ? branchActivityTimestamp(currentBranch) : 0, + }, + ] + : []), + ...snapshot.localBranches + .filter((branch) => { + const { aheadCount, behindCount } = branchSyncCounts(branch, snapshot); + return !branchHasUpstream(branch, snapshot) || aheadCount > 0 || behindCount > 0; + }) + .map((branch) => ({ + kind: "branch" as const, + key: `branch:${branch.name}`, + branch, + attention: branchAttention(branch, snapshot), + activity: branchActivityTimestamp(branch), + })), + ...snapshot.actionableForkBranches.flatMap((fork) => { + const branch = snapshot.localBranches.find( + (localBranch) => localBranch.name === fork.localBranchName, + ); + if (!branch) return []; + return [ + { + kind: "fork-branch" as const, + key: `fork:${fork.localBranchName}:${fork.remoteRefName}`, + branch, + fork, + attention: "behind" as const, + activity: branchActivityTimestamp(fork), + }, + ]; + }), + ...snapshot.stashes.map((stash) => ({ + kind: "stash" as const, + key: `stash:${stash.refName}`, + stash, + attention: "dirty" as const, + activity: stashActivityTimestamp(stash), + })), + ].toSorted((left, right) => { + if (left.kind === "working-tree" && right.kind !== "working-tree") return -1; + if (right.kind === "working-tree" && left.kind !== "working-tree") return 1; + const attention = ATTENTION_RANK[left.attention] - ATTENTION_RANK[right.attention]; + if (attention !== 0) return attention; + return right.activity - left.activity; + }); + + const renderWorkingTreeRow = () => { + const key = treeKey("work", "working-tree"); + const expanded = isTreeExpanded(key, true); + const { aheadCount, behindCount } = currentBranch + ? branchSyncCounts(currentBranch, snapshot) + : { aheadCount: 0, behindCount: 0 }; + return ( +
+
toggleTree(key, true)} + onKeyDown={(event) => toggleTreeFromKeyboard(key, event, true)} + onContextMenu={(event) => + openContextMenu( + event, + [ + { + id: "commit-selected", + label: "Commit selected changes", + disabled: + isActionRunning("changes-commit") || + gitAction.isPending || + selectedChangedFiles.length === 0, + }, + { + id: "stash-selected", + label: "Stash selected changes", + disabled: isActionRunning("changes-stash") || selectedChangedFiles.length === 0, + }, + contextMenuSeparator("discard-separator-before"), + { + id: "discard-selected", + label: "Discard selected changes", + destructive: true, + disabled: + isActionRunning("changes-discard-selected") || + selectedChangedFiles.length === 0, + icon: "trash", + }, + ], + { + "commit-selected": () => void runGeneratedPanelCommit(), + "stash-selected": () => void runGeneratedPanelStash(), + "discard-selected": discardSelectedChanges, + }, + ) + } + > + {expanded ? ( + + ) : ( + + )} + + Working tree +
+ {currentBranch ? {currentBranch.name} : null} + {changedFiles.length > 0 ? ( + + {changedFiles.length === 1 ? "1 file" : `${changedFiles.length} files`} + + ) : ( + clean + )} + +
+
+ {expanded ? ( +
{changesSection}
+ ) : null} +
+ ); + }; + + const workSection = ( +
+ {workItems.map((item) => { + switch (item.kind) { + case "working-tree": + return
{renderWorkingTreeRow()}
; + case "branch": + return branchRow(item.branch); + case "fork-branch": { + const fetchKey = `fork-fetch:${item.fork.localBranchName}:${item.fork.remoteRefName}`; + const detailsKey = treeKey( + "fork-details", + `${item.fork.localBranchName}:${item.fork.remoteRefName}`, + ); + return branchRow(item.branch, { + key: treeKey( + "fork-branch", + `${item.fork.localBranchName}:${item.fork.remoteRefName}`, + ), + detailsKey, + compareBaseRef: item.fork.remoteRefName, + syncCounts: { + aheadCount: item.fork.aheadCount, + behindCount: item.fork.behindCount, + }, + attention: "behind", + syncLabel: "Fetch", + syncState: "fetch", + syncActionKey: fetchKey, + fetchActionKey: fetchKey, + secondaryBadge: vs {item.fork.remoteRefName}, + onSync: () => + void runAction( + fetchKey, + () => + api?.vcs.fetchBranch({ + cwd, + branchName: item.fork.remoteRefName, + }) ?? Promise.resolve(), + ), + }); + } + case "stash": + return stashRow(item.stash); + } + })} +
+ ); + + const remotesSection = ( +
+ {localBranchesWithoutUpstream.length > 0 + ? localBranchesRow(localBranchesWithoutUpstream) + : null} + {snapshot.remotes.length === 0 && localBranchesWithoutUpstream.length === 0 ? ( +
No remotes configured.
+ ) : ( + snapshot.remotes.map(remoteRow) + )} +
+ ); + + return ( + <> +
+ {repositorySummary} +
+ {SECTION_ORDER.map((key) => { + switch (key) { + case "work": + return section( + key, + workSection, + void fetchActionableBranches()} + > + + , + ); + case "remotes": + return section( + key, + remotesSection, +
+ + void runAction( + "remotes-fetch-all", + () => api?.vcs.fetchAllRemotes({ cwd }) ?? Promise.resolve(), + ) + } + > + + + setAddRemoteOpen(true)}> + + +
, + ); + } + })} +
+
+ + + + Add remote + Register a Git remote for this repository. + + + setRemoteName(event.currentTarget.value)} + /> + setRemoteUrl(event.currentTarget.value)} + /> + + + + + + + + { + if (!open) setPublishRemoteTarget(null); + }} + > + + + Publish branch + + Choose the remote to set as upstream for{" "} + {publishRemoteTarget?.branch.name ?? "this branch"}. + + + + {snapshot.remotes.map((remote) => ( + + ))} + + + + + + + { + if (open) return; + setCompareBaseDialogTarget(null); + setCompareBaseQuery(""); + }} + > + + + Choose compare base + + Select the ref to compare with {compareBaseDialogTarget?.branch.name ?? "this branch"} + + + + { + if (!open) setCompareBaseQuery(""); + }} + > + }> + + + {compareBaseDialogTarget + ? (branchDetailsByRef.get(compareBaseDialogTarget.detailsKey)?.baseRef ?? + compareBaseOverrides.get(compareBaseDialogTarget.detailsKey) ?? + "Choose ref") + : "Choose ref"} + + + + +
+ setCompareBaseQuery(event.currentTarget.value)} + /> +
+ No refs found. + + {filteredCompareBaseRefs.map((refName) => ( + chooseCompareBase(refName)} + > +
+ + {refName} +
+
+ ))} +
+
+
+
+ + + +
+
+ { + if (!open) setDivergedSyncBranch(null); + }} + > + + + Sync diverged branch + + Choose how to reconcile local and upstream commits for{" "} + {divergedSyncBranch?.name ?? "this branch"}. + + + + + + + + + + + + + + Commit selected changes + + Provide a message, or leave it blank to auto-generate one. + + + + +