diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..e2ae593802a --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,26 @@ +# Version Control Panel Work + +The first-class Version Control panel includes a singleton right-panel surface, live VCS status watcher, Actionable and Remotes panel model, selected-file commit/stash flow, branch/commit/stash/remote actions, compare-base semantics, and Version Control panel RPC/contracts. On web, a logical project shared across environments renders one complete panel instance for each connected environment, with the active environment first and remote instances using the existing server-icon and environment-label treatment. Snapshots, status subscriptions, fetches, diffs, editor launches, and Git mutations stay routed through each instance's environment and cwd. Disconnected environments are omitted, failures remain isolated to their per-panel retry state, and only the active instance may update active-thread source-control metadata or open its standalone File surface. Background preview mini-player lifecycle changes and right-panel browser-surface reconciliation must leave the Source Control surface open, visible, active, and present exactly once. + +Web source-control rows use the shared Sidebar v2 rich-tooltip presentation and timing. Working tree, file, branch, commit, stash, and remote cards expose their full paths or refs and relevant status, timestamp, identity, URL, and line-change details without covering the row. File cards use trigger-scoped virtual anchors while retaining a common panel-aligned left edge. Nested action buttons keep their terse label tooltips and preserve the parent rich card only for the nested trigger in that same row, so unrelated tooltips elsewhere are unaffected. + +VCS status ignores internal `.git/` watcher events before refreshing local status and uses a conservative default automatic remote Git fetch interval. + +Provider-backed change-request lookups remain best-effort in the panel service. Provider/auth/CLI failures must not fail the whole panel snapshot or hide git-derived actionable branch rows. + +Version Control and source-control provider failures should preserve structured causes when normalized for panel RPC errors. GitLab, GitHub, Azure DevOps, and Bitbucket provider paths should keep provider-specific not-found/auth/missing-CLI details without collapsing structured process failures into generic strings. Web mutations reconcile VCS status and an authoritative panel snapshot after both success and failure before preserving a mutation error, so conflict-producing or partially applied operations remain visible. Azure DevOps commit-avatar lookups route through the organization encoded by the repository remote and use the stable Commits Get API version. + +Thread source-control metadata update failures should surface on the thread without overwriting unrelated thread errors, and successful source-control updates should clear only the source-control metadata error for that thread. Metadata update sequencing stays monotonic for the hook lifetime so a reopened thread key cannot let an old in-flight failure overwrite a newer successful checkout. Server-thread updates carry the active branch as `expectedBranch`, allowing the server to reject a stale metadata write instead of overwriting a newer branch/worktree transition. Grouped-project navigation retargets an open singleton Source Control surface to the active draft/thread environment and effective repository cwd, while metadata errors remain scoped to the originating environment/thread key and are pruned when that context is no longer retained. + +Required edge cases: the current default branch remains a valid default compare ref and retains that stable base in its own branch details, status-derived default branch names such as `develop` are preferred over hardcoded `main`/`master` guesses, compare-history pagination queries the selected comparison range, branch pull/fetch parsing handles slashful remotes and remote-looking local branch names without treating slashless local upstreams as remote refs, diverged normal merge sync is available only for the current branch, checked-out branch worktree paths fall back from porcelain worktree output to branch-format placeholders without failing on older Git versions, sibling worktree watcher refreshes keep root Actionable rows live while skipping stale/prunable worktree paths, working-tree refreshes that race an authoritative full snapshot cannot retain pre-mutation branch/remote/stash data, failed full snapshots release the in-flight full-refresh barrier so later working-tree refreshes can remain incremental, queued web refreshes still drain when the active refresh fails or is interrupted, branch sync and undo operations for checked-out branches target the owning worktree cwd, checkout and deletion remain rejected by both the client and server for branches already checked out in any worktree, cwd-scoped working-tree enrichment avoids cross-worktree file-detail reuse, selected-file commits omit pathspecs after staging, commit-hook output only enriches a failed Git result and never interrupts a still-running commit, merge refs are passed after `--`, tracked discard restore failures surface instead of being swallowed, fallback rename parsing preserves original paths, merged staged-plus-unstaged row stats are summed, collapsed mobile remotes hide their branch rows, mobile conflict-only rows open the working-tree diff side, failed mobile branch/stash details replace loading placeholders with errors, invalid mobile branch and stash dates are omitted instead of appearing as recent activity, and late-month relative dates do not fall through to `0 years ago`. + +`SOURCE_CONTROL.md` contains the detailed implementation requirements. + +Primary reference: + +- `SOURCE_CONTROL.md` + +## Development Ports + +- Web: `5742` +- Server/WebSocket: `13782` diff --git a/SOURCE_CONTROL.md b/SOURCE_CONTROL.md new file mode 100644 index 00000000000..a6f3bdc2e5f --- /dev/null +++ b/SOURCE_CONTROL.md @@ -0,0 +1,301 @@ +# Version Control Panel + +T3 Code includes Git-backed Version Control surfaces in the web right panel and the native mobile app. Each per-environment panel instance is scoped to one 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 client UI code. On web, the right-panel surface federates those instances for every connected environment containing a project in the active logical repository group, so the current checkout, sibling worktrees, actionable branches, remotes, stashes, and their operations remain visible together without moving Git execution into the browser. + +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` through `ChatView.sourceControl`: server threads persist through `thread.meta.update`, while draft conversations update local draft thread context. Server-thread branch updates include the active thread branch as `expectedBranch`, so the server preserves newer branch/worktree metadata if another update wins while the Git action is in flight. Server-thread metadata failures surface in the chat error banner through source-control-specific per-thread state. Dismissal clears that local metadata error without pretending provider session errors are dismissible, and overlapping metadata updates are sequenced per thread for the hook lifetime so stale in-flight failures cannot overwrite a newer successful checkout even if a thread is closed and reopened with the same key. + +## Performance And Freshness + +The initial panel load, window focus/visibility refresh, explicit Git action completion, pull-to-refresh, and manual refresh use an authoritative full snapshot. VCS watcher updates use a working-tree refresh: the server reloads status plus current and sibling worktree change groups, then merges those slices into the last full snapshot for that cwd. If branch identity, repository/remote availability, upstream state, ahead/behind counts, provider metadata, or change-request status differs from the cached snapshot, the server automatically performs a full snapshot instead. A missing cache also falls back to full. A working-tree refresh that starts while a full snapshot is in flight is promoted to a full snapshot, so a newer watcher request cannot reuse pre-mutation branch, remote, fork, or stash data and supersede the authoritative refresh. Full-snapshot completion advances the per-cwd barrier on success, failure, or interruption, so a failed newer request does not leave later working-tree refreshes permanently promoted. Snapshot requests carry monotonically increasing per-cwd revisions so an older request that completes late cannot overwrite a newer server cache entry. + +Automatic all-remotes fetches preserve the panel's complete remote intent, including secondary remotes. They are keyed by the repository's Git common directory, share in-flight work across worktrees/panel instances, and reuse a successful fetch for five minutes. Mount, focus, and periodic refresh paths pass through this freshness check, so returning to a window obtains current remote refs when the coordinated fetch is stale without producing focus storms. A failed fetch is retained only for a short cooldown. Explicit Fetch buttons set `force`, invalidate the freshness entry, and execute a new all-remotes fetch; this keeps manual freshness guarantees while preventing automatic callers from starting duplicate `git fetch --all` processes. + +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/source-control/SourceControlEnvironmentPanel.tsx` +- `apps/web/src/components/source-control/useSourceControlPanelController.ts`, `useSourceControlPanelState.tsx`, `useSourceControlPanelRefresh.ts`, `useSourceControlPanelActions.tsx`, and `useSourceControlPanelExpansion.tsx` for the per-environment controller lifecycle +- `apps/web/src/components/source-control/SourceControlPanelView.tsx`, `SourceControlPanelWorkingTree.tsx`, `SourceControlPanelBranches.tsx`, and `SourceControlPanelRepositories.tsx` for focused panel surfaces +- `apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx` and `SourceControlPanelRows.tsx` for shared presentation +- `apps/web/src/components/source-control/SourceControlPanelCache.ts`, `SourceControlPanelModel.ts`, and `SourceControlPanel.logic.ts` for cache and pure behavior +- `apps/web/src/state/sourceControlPanel.ts` +- `apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx` +- `apps/mobile/src/features/version-control/VersionControlRouteView.tsx` and `VersionControlRouteComponents.tsx` +- `apps/mobile/src/features/version-control/versionControlModel.ts` +- `apps/mobile/src/features/version-control/useVersionControlPanelApi.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/ChatView.sourceControl.ts`, which exports source-control right-panel availability/surface helpers and `useSourceControlThreadMetadataRouting` for server and draft thread metadata routing +- `apps/web/src/components/RightPanelTabs.tsx` +- `apps/server/src/sourceControl/SourceControlPanelService.ts` +- `apps/server/src/sourceControl/SourceControlPanelReaders.ts` and `SourceControlPanelActions.ts` +- `apps/server/src/sourceControl/SourceControlPanelParsers.ts` and `SourceControlPanelStatusParsers.ts` +- `apps/server/src/vcs/VcsStatusBroadcaster.ts` +- `apps/server/src/vcs/VcsLocalWatch.ts` +- `apps/server/src/ws.ts` +- `packages/contracts/src/rpc.ts` +- `packages/contracts/src/ipc.ts` +- `packages/client-runtime/src/state/sourceControl.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 a thread or draft-thread ref exists for right-panel state and the active project resolves to a repository cwd. + +On mobile, `Version Control` is a native full-screen route over the active thread. It is available directly in the Git header menu beside the quick commit/push action and from the existing Git overview menu that contains commit and branch/worktree actions. Returning from the route closes it without changing the active workspace/thread selection. Both the Version Control route and its dedicated diff route are workspace overlays, so opening a file diff does not replace the Home/Thread path used by the adaptive workspace layout. Publishing an unpublished branch prompts for its destination when the repository has multiple remotes instead of silently preferring `origin`. + +Version Control uses the shared right-panel tab shell, including the standard context menu, middle-click tab close behavior, and centralized add-surface metadata in `RightPanelTabs` for both the empty state and the add-surface menu. + +Background preview mini-player lifecycle changes and right-panel browser-surface reconciliation remain orthogonal to an active singleton Source Control surface. Adding or removing browser surfaces and opening or closing a mini-player must leave the Source Control surface open, visible, active, and present exactly once. + +`ChatView` keys the mounted `SourceControlPanel` by active environment, thread, and effective Git cwd. Switching between conversations, projects, or worktrees therefore creates a fresh panel instance for that context instead of letting repository state from the previous thread bleed into the next one. Grouped-project draft navigation resolves the panel target from the active draft/thread ref and effective Git cwd on every render, so the singleton `source-control` surface retargets without reusing another grouped project's repository context. Source-control metadata errors remain keyed to the originating scoped environment/thread key, do not appear on another grouped-project draft, and are pruned when that context is no longer retained. The panel pairs that remount boundary with a bounded in-memory state cache keyed by environment, thread, cwd, and worktree path, so returning to a previously opened Version Control panel can render its last snapshot and UI state immediately while the normal refresh/fetch path updates it in the background. + +When the active logical project spans multiple environments, `ChatView` supplies every physical project environment together with its live connection state. The Version Control surface omits disconnected environments entirely and mounts one complete panel instance for each connected environment, with the active environment first. Environment headers reuse the local-monitor and remote-server visual language from the workspace and Sidebar V2 surfaces, show the environment label and cwd, and explicitly mark remote and current contexts. A failed request from a still-connected environment remains isolated inside that environment's normal panel error and retry surface instead of hiding successful sibling environments. + +All Git queries, status streams, automatic and manual fetches, inline diffs, editor launches, and mutations remain bound to the environment and cwd of their owning panel instance. Only the active environment instance can update the active thread's branch/worktree metadata or open the thread-bound standalone File surface; non-active environments retain inline file diffs and environment-scoped preferred-editor actions without attaching a foreign workspace to the active conversation. + +`ChatView.sourceControl.ts` owns the source-control-specific right-panel availability, visible-surface filtering, and open-surface callback so upstream chat timeline and minimap changes in `ChatView.tsx` stay separate from Version Control panel glue. + +Right-panel integration is owned by: + +- `apps/web/src/rightPanelStore.ts` +- `apps/web/src/components/RightPanelTabs.tsx` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/ChatView.sourceControl.ts` +- `apps/web/src/state/sourceControlPanel.ts` + +Native navigation integration is owned by: + +- `apps/mobile/src/Stack.tsx` +- `apps/mobile/src/features/threads/ThreadGitControls.tsx` +- `apps/mobile/src/features/threads/git/GitOverviewSheet.tsx` + +## Native Mobile Screen + +The mobile screen preserves the web panel's compact repository summary plus `Actionable` and `Remotes` information architecture, adapted to native touch interaction. The summary is deliberately unboxed so it cannot be mistaken for an interactive row: it shows the current branch, compact `↑x`/`↓y` sync counts, `No upstream` only when applicable, and either `Clean` or the current `x file(s) +x -x` working-tree totals. Resizable desktop sections become collapsible native sections, row actions are touch-sized pills shown only while their owning row is expanded, remote branch rows are hidden together with their collapsed remote, and pull-to-refresh complements live VCS status updates and the same best-effort fetch-on-open behavior. The native route remembers each cwd it has automatically fetched during its mounted lifetime, so returning from a file diff does not start another fetch for the same repository. + +Native snapshot refreshes are sequenced so an older fetch, status-triggered refresh, or mutation refresh cannot overwrite newer repository state. Every emitted VCS status value triggers a refresh even when its serialized summary is unchanged. Mutation completion accepts an authoritative full snapshot before requesting a VCS status refresh, so the later working-tree refresh merges against the post-mutation server cache instead of superseding the full refresh with stale branch, remote, stash, or fork data. Loaded native snapshots retain their owning cwd and are not exposed after the selected thread switches to another checkout, so the new cwd cannot be paired with the previous repository's files while its refresh is in flight. A cwd without a snapshot remains in its initial loading state only until that cwd's current request settles; failed initial loads expose the error and pull-to-refresh controls instead of remaining on the loading screen. When an accepted snapshot changes, cached branch and stash details are invalidated together with their expanded/loading rows, and late detail responses from the previous snapshot are ignored. Supplemental working-tree enrichment failures are isolated per checkout so one unavailable sibling worktree does not hide the rest of the repository snapshot. Failed mutations still refresh repository state before preserving their action error in mutation-owned state, so a racing status refresh cannot erase the failure while conflict-producing operations remain visible. Failed remote creation keeps the add-remote form open with its entered values intact. + +The native `Actionable` section includes the current and dirty sibling working trees, local branches requiring attention, same-name fork branch notices, and stashes. Working-tree cards preserve newly-added-file default selection, remove selections for checkouts that become clean, use tri-state-style select-all behavior, show aggregate staged-plus-unstaged stats and rename source paths, and expose selected-file commit/stash/discard operations. Pressing a working-tree, commit, or stash file opens a dedicated diff screen backed by the same native Review renderer, parsed diff model, syntax highlighting, theme, display metrics, and horizontal scrolling as the rest of the mobile app; unsupported patches retain a raw-text fallback. Staged-only files request the cached diff side, while mixed, unstaged, and conflict-only rows request the working-tree side so unmerged paths do not open an empty staged diff. Native snapshot loading applies the cwd-scoped working-tree enrichment RPC before composing those cards, so untracked files contribute their real inserted-line totals; selected-file discard separately clears unstaged and staged sides for mixed rows, and conflict-only selections that have neither side stay excluded from the discard count and action availability. Branch cards expose thread-aware checkout, publish/push/pull/fetch/diverged sync choices, merge, rebase, delete confirmation, and comparison details. Expanding an actionable branch renders its ahead and behind commits rather than the aggregate comparison-file list; every commit carries a right-aligned green ahead or yellow behind arrow, and only expanding that commit reveals its changed files. Returning from a file diff preserves the expanded branch and commit state. Failed branch, fork-comparison, and stash detail requests replace the loading placeholder with the request error until the row is retried, instead of leaving an indefinite loading state; a successful retry clears that same shared detail-error banner without clearing an unrelated mutation error. Branches checked out in sibling worktrees keep deletion disabled, while merge sync is offered only when the current cwd or a branch-owned worktree actually has that branch checked out. Stash cards expose apply, pop, drop, and expandable changed-file details, including rename source paths; expansion and detail caches use the stash commit hash when available so positional ref renumbering cannot bind old details to another stash. + +The native `Remotes` section supports fetch, add, remove, and a deliberately shallower branch inspection model than desktop. Remote branches do not load commit trees on mobile; their rows show local/remote identity icons, current/default/worktree tags, and available ahead/behind counts, then reveal applicable checkout, fetch/sync, merge, rebase, and delete actions when pressed. A remote branch binds to a local counterpart only when that local branch tracks the row's exact remote-qualified ref, so duplicate branch names across remotes cannot borrow another upstream's counts or actions. All mutations use the existing panel RPC atom families; branch switching deliberately reuses the mobile thread Git action path so server-thread metadata and active checkout context continue to update together. + +Cross-platform panel presentation rules that affect both web and mobile live in `@t3tools/shared/sourceControl`, including staged/unstaged file merging and sync-vs-compare branch state. Mobile-only list composition and touch presentation helpers remain in `versionControlModel.ts`. + +## 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, with local path filtering and debounced refresh signal handling factored into `VcsLocalWatch`. Internal `.git/` events are ignored before any refresh decision, file events are debounced, remaining paths are checked against Git ignore rules when possible, and explicit local refreshes publish a local status update even when the summary fingerprint is unchanged so same-path/same-stat file edits can refresh live working-tree diffs. If a local watch fiber has already terminated while an entry remains subscribed, the next subscriber replaces the stale watcher instead of attaching to a dead fiber. + +This keeps externally-created changes visible without requiring a window blur/refocus cycle, while avoiding repeated no-op refreshes for gitignored files and unrelated background churn. Root panel streams also retain local watchers for sibling worktrees discovered from `git worktree list --porcelain`; stale/prunable worktree paths that no longer exist on disk are filtered before watcher retention, and sibling filesystem events force-publish a root local update so the panel can reload sibling `Actionable` rows even when the root checkout status fingerprint is unchanged. Local watcher subscriptions are acquired with stream-scoped finalizers before the initial status load, so a failed stream setup still releases every watcher retained for the subscription. + +The panel only shows the full `Loading repository state...` placeholder when no cached or loaded snapshot exists for the active thread/cwd. When a cached snapshot exists, the panel renders that previous repository state immediately and shows the existing refresh indicator while `panelSnapshot` and remote fetch work catch up, even if the fetched snapshot fingerprint matches the cache. Cached panel state is process-local rather than local-storage persisted and is bounded to recent contexts; it includes the panel snapshot, collapsed/expanded sections, branch and stash detail maps, compare-base overrides, selected paths, lazy working-tree enrichment data, and loaded file diffs. In-flight diff loading rows are intentionally not restored from cache, so a reopened panel does not get stuck showing an old loading row without a live request. Refresh requests that arrive while another refresh is active are coalesced, with full refreshes taking priority, and the queued refresh is still drained if the active request fails or is interrupted. + +## 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. +- Local branches with open GitHub, GitLab, Azure DevOps, or Bitbucket change requests whose base branch exists on the matching remote and is ahead of the local branch. +- Stashes. +- Other checked-out worktree branch labels when available. + +Checked-out worktree labels are resolved from `git worktree list --porcelain` first. When that output is unavailable or empty, the service falls back to `git branch --format` worktree placeholders where supported; older Git versions that do not support `%(worktreepath)` fall back to the branch format without paths instead of failing the whole panel snapshot. + +When the panel is opened from the root checkout and other non-root worktrees are active, dirty sibling worktrees are shown as separate Actionable entries keyed by their checked-out branch and worktree path. These entries are populated by running the same status and diff snapshot commands in the sibling worktree cwd, so staged, unstaged, untracked, and conflicted files remain scoped to the checkout where they exist. Sibling worktree rows use the same lazy file enrichment path as the root working tree, keyed by worktree cwd so same relative paths in different checkouts cannot share stale rename or untracked-stat details. Sibling worktree rows are omitted when clean. + +Fully synced local branches are omitted from `Actionable`; they remain visible under `Remotes` when they track a same-name remote branch. + +Actionable branch sync state is intentionally separate from branch comparison state. A local branch can have a configured Git upstream/base such as `upstream/main` because it was created from that ref, while still being unpublished as `origin/` or another remote branch. The panel treats only same-name remote tracking refs as publish/sync upstreams for branch actions. Different-name refs remain valid compare bases and can still make the branch appear in `Actionable`, but they do not make the branch `behind` or `diverged` for sync purposes. In that state the row's sync action is `Publish`, and publishing targets the local branch name on the chosen remote rather than the base ref. + +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 server also checks open change requests for every local branch across all configured remotes whose fetch URL maps to a supported provider: GitHub, GitLab, Azure DevOps, and Bitbucket. For each matching open PR/MR where the local branch is the head branch, the panel compares the local branch only against the found change request's base branch on that same remote. A PR/MR-derived Actionable row is shown only when the local branch is behind that remote base branch; if the branch is already current with, ahead of, or unrelated to the base branch, no Actionable entry is shown. Self-hosted GitLab lookups scope `glab mr list --repo` with a full project URL instead of an ambiguous scheme-less host/path locator, without duplicating a configured base-URL path prefix that is already present in the remote repository path; GitLab.com keeps the normal namespace/project form. Provider lookup is best-effort: authentication, CLI/API, or unsupported-remote failures omit PR/MR-derived rows without blocking the Git snapshot, but provider-specific errors still preserve structured causes for diagnostics. + +Client-side Actionable/Remotes expansion, row selection, working-tree enrichment state, and the per-thread/per-worktree panel-state cache are owned by `apps/web/src/components/source-control/SourceControlPanel.tsx`, while `apps/web/src/state/sourceControlPanel.ts` owns the environment-scoped panel RPC wrapper and presentation-state helper. + +The `Actionable` header has a `Fetch` action. The panel runs the same fetch-all-remotes path once when it first opens for a cwd, including after switching to a different conversation/worktree cwd, so upstream changes that have not yet been fetched can appear without requiring the user to press `Fetch`. The panel also periodically fetches remotes every five minutes so local upstream status and same-name fork status stay fresh while keeping idle network and Git churn conservative. + +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. Hovering anywhere on the row reveals its branch, full worktree path, file count, and aggregate added/removed line indicators. 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. + +Dirty sibling worktree rows expand to the same compact changed-file list and selection model. File diff, right-panel File preview, open-in-editor, stash, commit, and discard operations for those rows target the sibling worktree path, not the panel's root cwd. The File surface can carry a source-control cwd override for these rows while normal file surfaces remain workspace-relative. This prevents same relative paths in different checkouts from sharing diff state or executing Git actions against the wrong worktree. + +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. When staged and unstaged entries for the same file are merged into one displayed row, the displayed stats sum both sides instead of taking only the larger staged or unstaged count. These numbers are aggregate staged-plus-unstaged churn, not a de-duplicated net diff against `HEAD`; for example, a selected file with `+2/-1` staged and `+3/-4` unstaged is shown as `+5/-5`. + +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. Generated selected-file stash summaries apply literal pathspec semantics to diff and status input, exclude unrelated paths, and include both staged and unstaged tracked changes for an all-mode stash. + +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. Hovering anywhere on a file row reveals its full repository-relative path and line-change indicators; rename tooltips include the original path as well. The full-width row trigger keeps the card available across the whole item, while a panel-aligned virtual anchor keeps every left-positioned file card on the same horizontal axis as the top-level item cards regardless of list nesting. Expanding a file row opens an inline diff for that file change in working-tree, commit, stash, branch, and compare file lists. Inline file diffs apply syntax highlighting on their initial expansion; asynchronous worker completion updates the mounted viewer so users do not need to collapse and reopen the row to consume highlighted output. Rename diff requests preserve both the destination path and original path, so `R` rows compare against the deleted source side instead of rendering the destination as a whole new file. Mixed staged-plus-unstaged working-tree rows open the unstaged diff by default because it is the still-editable part of the row; staged-only rows open the staged diff. 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 file list is not a nested vertical scroller. Rows render in normal flow inside the `Actionable` section's existing overflow area, so mouse-wheel and mobile swipe gestures over changed-file rows scroll the panel section itself. Working-tree enrichment remains lazy: each rendered row registers with an `IntersectionObserver` and queues enrichment when it enters the viewport or the 600px prefetch margin, with a fallback that queues immediately when the observer API is unavailable. + +The `Working tree` context menu includes selected-file commit and stash actions plus a separated destructive `Discard selected changes` action. + +The web panel shares the Sidebar v2 tooltip presentation and timing for informational row targets: rich glass cards open to the left after a short delay, close immediately, and switch instantly between adjacent targets. Action buttons nested inside branch, commit, and file rows retain their terse top-positioned label tooltips, while those rich item cards opt out of Base UI's normal parent-close behavior only for a nested trigger inside the same row, so both remain visible as the pointer moves across that row's actions without affecting unrelated open tooltips elsewhere on the page. + +## 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. Hovering anywhere on a branch row reveals its full name plus available upstream, worktree, sync-state, and readable activity-time details. Anchoring the tooltip to the full row keeps its left-positioned card outside the panel content instead of overlapping the row's right side. Long branch refs and worktree paths use constrained metadata columns with forced wrapping so the animated tooltip viewport does not clip their trailing text. The sync-state rules are isolated in `SourceControlPanel.logic.ts` so Actionable and Remotes use the same definition of published, behind, ahead, diverged, and local-only. + +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 against its same-name sync upstream it is diverged; no separate diverged badge is needed. Ahead/behind counts against a different compare base may still appear in fork/change-request rows or expanded branch details, but those counts do not drive the branch sync action. + +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. Hovering those controls keeps the branch tooltip visible instead of replacing it with a nested action tooltip. Row-level keyboard expansion only handles key events targeted at the row itself, so pressing Enter or Space on a nested action button does not also expand or collapse 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 force pull, normal merge sync, or force push. Normal merge sync is available only when the diverged row is the currently checked-out branch, matching Git's working-tree merge semantics; the merge button is disabled for non-current diverged branch rows. Non-current diverged branch rows keep force pull and force push available without implicitly changing the user's checkout. Modifier-key tooltips stay terse; for example pull can note `Shift: reset` and `Option: fetch`. + +When a branch row represents a branch checked out in another worktree, branch sync and undo operations target that branch's worktree path instead of the panel root cwd. This applies to publish, push, pull, fetch, force-pull, force-push, the current-branch merge-sync path, and undoing an individual or latest commit, so Git operations run from the checkout that owns the branch. Undo uses a soft reset in that checkout and leaves the reverted changes in its working tree. Checkout and deletion stay disabled for any branch already checked out in the current or a sibling worktree. + +## 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 configured upstream/base when available, otherwise the repository default comparison ref. The repository default comparison ref remains available even when it is the currently checked-out branch, so current-default-branch rows still have a stable compare target. The default ref uses VCS status default-branch detection when the checked-out branch is the repository default, so repositories whose default branch is named something like `develop` do not fall back to an unrelated non-current branch. This base can be a different-name ref such as `upstream/main`; in that case it is a comparison base only, not proof that the branch has been published. Actionable same-name fork rows default this base to the other remote branch they are tracking for updates. Actionable PR/MR-derived rows default this base to the found change request's remote base branch. 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. + +Branch detail loading is keyed by the rendered detail surface, not only by branch name. Canonical branch rows keep the branch `name` and `fullRefName` entries synchronized, while actionable fork rows use an isolated `fork-details::` key so paging or loading one comparison base does not overwrite the branch-owned details for another base. Requests are sequenced per rendered detail key, so a late response for an earlier compare-base choice cannot replace the newer selection's commits or counts. Paginated commit responses additionally retain their requested comparison base and are discarded when the current detail has moved to another base, preventing old pages from mixing into the new history. Commit pages append in server order and de-duplicate by SHA, which keeps reloads and overlapping pages from duplicating rows. Commit parsing retains valid empty subjects and embedded tabs so history counts remain consistent with pagination. If cached expanded tree state is restored without a matching branch detail payload, the next panel snapshot refresh hydrates the missing expanded branch details even when the snapshot fingerprint itself is unchanged. + +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 follow the selected comparison base in the expanded details. Local-only or unpublished branches still support the `vs. ...` compare-base row, including configured base refs such as `upstream/main`; their branch sync action remains publish unless they track a same-name remote branch. + +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. Showing a user avatar image for commit authors is required and intended behavior, but provider avatar lookup is opt-in per source-control provider and disabled by default. When enabled for a supported repository remote, the server resolves the provider account avatar URL for the commit author (for example GitHub account `avatar_url`, GitLab Avatar API URLs, Azure DevOps commit author `imageUrl`, or Bitbucket commit author avatar link) and sends it as `authorAvatarUrl`; the web panel falls back to compact initials only when the setting is disabled, the provider/avatar URL is unavailable, or image loading fails. Azure DevOps change-request listing and commit lookup pass the organization encoded by the HTTPS or SSH repository remote explicitly to the Azure CLI; commit lookup uses the stable Commits Get `7.1` API version, so ambient CLI defaults cannot silently redirect either lookup to another organization. The panel must not silently regress to initials-only rows when the option is enabled, and provider avatar lookup must not be replaced by generated local avatars or unofficial email-derived third-party avatar URLs. GitLab's official Avatar API is allowed because it is the provider's documented lookup path; that endpoint may return external avatar-service URLs such as Gravatar or Libravatar, and those URLs are passed through because enabling the setting is an explicit opt-in to provider avatar lookup. 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. + Branch/tag labels classify remote refs using the repository's known remote names, not a slash heuristic, so local branch names such as `feature/login` remain local labels. +- Tag labels use a tag icon before the tag name. + +Commit tooltips use the shared rich tooltip card and are structured panels with author name and avatar, short SHA, relative and readable commit time, branch/tag labels, message, and line-change indicators. The card remains visible while hovering the commit's action buttons. + +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. Hovering the message reveals the full message, ref, branch context, and readable creation time. 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. Restored stash expansion and cached stash details are keyed by the stash commit hash when Git reports one, falling back to the positional ref only when no hash is available, so `stash@{n}` renumbering after stash create/drop operations does not show details for the wrong stash. + +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`. If cached expanded tree state is restored without a matching stash detail payload, the next panel snapshot refresh hydrates the missing expanded stash details instead of leaving the expanded row empty until the user collapses and reopens it. + +## 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. Hovering the remote name reveals the complete fetch and push URLs plus its branch count. Remote action buttons appear on hover/focus and include fetch and remove. + +When local-only or unpublished branches exist, `Remotes` also shows an `unpublished` tree row with those branches and an `x branch(es)` secondary label. Publishing one branch sets its same-name remote 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, open PR/MR base-branch checks across configured GitHub/GitLab/Azure DevOps/Bitbucket remotes, 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. Publish/push handling on the server only reuses a configured upstream when that upstream resolves to the same branch name; otherwise it pushes `:refs/heads/` on the selected/default remote so a base ref such as `upstream/main` cannot become the push target. Branch deletion accepts only the branch name from the client; the server resolves whether that name is a local branch or a remote branch from the current panel snapshot before choosing local `git branch -d/-D` or remote `git push --delete`. The server rejects deletion when the resolved local branch is current or has a worktree path, preserving the checked-out-worktree guard even if a non-web client bypasses the disabled action. + +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; copied rows send only their selected destination because the original is still a separate live file. Rename source paths are preserved for normal porcelain status entries and for fallback numstat parsing, including line-based fallback output. Porcelain status paths are decoded from Git's quoted path format before matching numstat output or rendering rows, so non-ASCII and escaped path names use the real repository path. Shared staging, unstaging, temporary intent-to-add, selected-file stash, and every discard classification/mutation command treat selected paths literally. Selected-file commits construct a temporary index from `HEAD`, or an empty index when `HEAD` is unborn, stage only the selected paths into that index with literal-pathspec semantics, generate the message and commit without pathspecs through that isolated index, then synchronize the selected paths into the real index only after the commit succeeds. This keeps mixed selected files complete while excluding deselected changes that were already staged in the user's real index; those deselected changes remain staged after the commit, while generation or commit failures leave the real index untouched. If the post-commit real-index synchronization fails or defects, the server logs the cleanup failure but still reports the already-created commit as successful so the caller refreshes against the advanced `HEAD`. 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. If the tracked unstaged restore step fails, the failure is reported to the panel instead of being swallowed after staged cleanup succeeds. Compare RPC results preserve left-to-right direction, including reversing the patch when the working tree is the left operand and a branch or stash is on the right. Switching a remote ref creates a local tracking branch; configured remote names, including names containing slashes, determine the local branch name. When the remote branch's short name already belongs to a local branch tracking another upstream, Git chooses the next available suffixed local name instead of checking out the remote ref in detached-HEAD state. Merge and rebase operations pass the selected ref after a positional `--` separator so option-shaped refs cannot be interpreted as command flags. + +## Error Handling + +Git action failures surface through the panel error state and existing toast/error paths. Web and native mutations reconcile VCS status and an authoritative panel snapshot after success or failure, then preserve the mutation error after reconciliation, so conflicts and other partially applied results become visible without losing the failure message; if mutation and reconciliation both fail, the original mutation error remains the user-facing error. 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. Interrupt-only atom command results are treated as cancellation rather than user-visible failures. Native detail and file-diff reads retry one interrupted force-refresh request; repeated detail interruptions remain silent, while a repeated file-diff interruption exits the loading state with a recoverable error. Generated selected-file commits inspect the returned command result, so failed commit generation or execution keeps the dialog path honest by surfacing the failure instead of silently refreshing. Commit progress output can classify known hook failures to enrich a failed Git result, but diagnostic text never interrupts a still-running commit or replaces its authoritative exit status. 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. + +Server-side panel errors preserve a sanitized cause diagnostic when wrapping lower-level failures into `GitCommandError`, so diagnostics can still distinguish process failures, provider failures, and command output failures even when the UI receives a normalized panel error. The preserved cause is bounded to a small plain object before it crosses the server contract, and the existing capped panel error block remains the user-facing size guard. Source-control provider adapters keep provider-specific missing-CLI, authentication, not-found, decode, and API details in their typed errors, then wrap common provider failures through `sourceControlProviderError` so GitHub, GitLab, Azure DevOps, and Bitbucket expose aligned `SourceControlProviderError` fields: provider, operation, cwd, sanitized reference/repository, detail, command when available, and original cause. Typed GitLab CLI errors own GitLab process-failure normalization and preserve structured process failures. + +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 + +Playwright coverage against the throwaway repository at `~/Sites/throwaway` includes 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 internal `.git/` event filtering and 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. + +Focused unit coverage includes the sync-vs-compare split: a local branch configured against `upstream/main` is treated as unpublished/publishable instead of diverged, a same-name remote tracking branch remains a normal sync upstream, server-side publishing targets the local branch name even when Git reports a different configured upstream/base ref, branch sync and undo operations use a branch worktree cwd when one is present, checked-out branches disable checkout and deletion, branch deletion is resolved from the server snapshot, Source Control thread metadata updates send the prior active branch as `expectedBranch`, grouped-project draft switching retargets the singleton Source Control surface by scoped thread ref and cwd without leaking or retaining the previous context's metadata error, background preview mini-player and browser-surface state changes leave an active singleton Source Control surface open and visible, the current default branch remains the default compare ref, branch worktree paths fall back from porcelain worktree output to branch-format placeholders without failing on older Git versions, sibling worktree watcher paths are parsed from porcelain output, missing sibling worktree paths are skipped before watcher retention, stream-scoped watcher acquisition releases retained local watchers if initial status loading fails, tracked discard restore failures are surfaced, fallback line-based rename parsing preserves source paths, porcelain quoted paths decode to real paths, rename diff loading sends both original and destination paths for staged and unstaged working-tree rows, mixed staged-plus-unstaged rows keep their unstaged side, merged working-tree row stats are summed, selected-file commits use literal paths, omit commit pathspecs, leave the real index untouched on commit failure, report success after a post-commit index-sync failure, and wait for Git's exit status before using hook output to enrich a failed commit, merge and rebase refs are passed after `--`, destructive Electron context-menu items remain grouped after an explicit separator, fork-branch load-more loading state is keyed by rendered details key, cached snapshots still show the ready-state refresh indicator while revalidating, queued refreshes drain after a failed or interrupted active refresh, restored expanded branch/stash rows hydrate missing details even on same-fingerprint refreshes, native remote branches remain hidden while their remote is collapsed, invalid native branch and stash dates are omitted instead of appearing as recent activity, late-month relative dates do not fall through to `0 years ago`, panel error wrapping preserves original causes, provider adapter errors preserve sanitized transport context through `sourceControlProviderError`, self-hosted GitLab MR lookup uses a full project URL for `--repo` without duplicating URL path prefixes, sibling right-panel File previews carry a cwd override so they read from the source worktree, and typed GitLab CLI errors preserve structured process failures. + +Before considering source-control changes complete, run the focused source-control checks plus the repository-wide checks: + +```sh +pnpm exec vp test run apps/server/src/sourceControl/SourceControlPanelService.test.ts apps/server/src/vcs/VcsStatusBroadcaster.test.ts apps/server/src/vcs/GitVcsDriverCore.test.ts +pnpm exec vp test run apps/web/src/components/source-control/SourceControlPanel.logic.test.ts apps/web/src/state/sourceControlPanel.test.ts +pnpm exec vp test run packages/contracts/src/git.test.ts packages/shared/src/git.test.ts packages/client-runtime/src/state/vcsAction.test.ts +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 +``` + +Native Version Control model coverage lives in `apps/mobile/src/features/version-control/versionControlModel.test.ts` and can be run with: + +```sh +(cd apps/mobile && pnpm exec vp test run src/features/version-control/versionControlModel.test.ts) +``` diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1d..11681243f3c 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -113,6 +113,65 @@ describe("ElectronMenu", () => { }).pipe(Effect.provide(TestLayer)), ); + it.effect("inserts a destructive separator when the first destructive item starts the menu", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.showContextMenu({ + window: makeWindow(), + items: [ + { id: "delete-draft", label: "Delete draft", destructive: true }, + { id: "copy", label: "Copy" }, + { id: "delete-file", label: "Delete file", destructive: true }, + ], + position: Option.none(), + }); + + const template = buildFromTemplateMock.mock.calls[0]?.[0] as + | Electron.MenuItemConstructorOptions[] + | undefined; + assert.deepEqual( + template?.map((item) => item.type ?? item.label), + ["Delete draft", "Copy", "separator", "Delete file"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("keeps destructive items grouped after an explicit separator", () => + Effect.gen(function* () { + buildFromTemplateMock.mockImplementation(() => ({ + popup: (options: Electron.PopupOptions) => { + options.callback?.(); + }, + })); + + const electronMenu = yield* ElectronMenu.ElectronMenu; + yield* electronMenu.showContextMenu({ + window: makeWindow(), + items: [ + { id: "copy", label: "Copy" }, + { id: "separator", label: "", separator: true }, + { id: "discard", label: "Discard", destructive: true }, + { id: "delete", label: "Delete", destructive: true }, + ], + position: Option.none(), + }); + + const template = buildFromTemplateMock.mock.calls[0]?.[0] as + | Electron.MenuItemConstructorOptions[] + | undefined; + assert.deepEqual( + template?.map((item) => item.type ?? item.label), + ["Copy", "separator", "Discard", "Delete"], + ); + }).pipe(Effect.provide(TestLayer)), + ); + it.effect("defers popupTemplate side effects until the returned Effect runs", () => Effect.gen(function* () { const popupMock = vi.fn(); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c241..c1ed717d53c 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -61,12 +61,26 @@ export class ElectronMenu extends Context.Service< function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] { const normalizedItems: ContextMenuItem[] = []; + let lastWasSeparator = false; for (const sourceItem of source) { if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") { continue; } + if (sourceItem.separator === true) { + if (normalizedItems.length === 0 || lastWasSeparator) { + continue; + } + normalizedItems.push({ + id: sourceItem.id, + label: "", + separator: true, + }); + lastWasSeparator = true; + continue; + } + // Header items are decorative section labels for the web fallback only — // Electron's native menu has no equivalent affordance, so we skip them. if (sourceItem.header === true) { @@ -78,6 +92,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM label: sourceItem.label, destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, + separator: false, }; if (sourceItem.children) { @@ -89,6 +104,11 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM } normalizedItems.push(normalizedItem); + lastWasSeparator = false; + } + + if (normalizedItems.at(-1)?.separator === true) { + normalizedItems.pop(); } return normalizedItems; @@ -141,11 +161,25 @@ export const make = Effect.gen(function* () { ): 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" }); - hasInsertedDestructiveSeparator = true; + if (item.separator === true) { + if (template.length > 0 && !lastWasSeparator) { + template.push({ type: "separator" }); + lastWasSeparator = true; + } + continue; + } + + if (item.destructive && !hasInsertedDestructiveSeparator) { + if (template.length > 0) { + if (!lastWasSeparator) { + template.push({ type: "separator" }); + lastWasSeparator = true; + } + hasInsertedDestructiveSeparator = true; + } } const itemOption: Electron.MenuItemConstructorOptions = { @@ -165,6 +199,7 @@ export const make = Effect.gen(function* () { } template.push(itemOption); + lastWasSeparator = false; } return template; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 4cf787d9ce5..e8a8da0cdd2 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -30,6 +30,8 @@ import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; +import { VersionControlRouteScreen } from "./features/version-control/VersionControlRouteScreen"; +import { VersionControlDiffRouteScreen } from "./features/version-control/VersionControlDiffRouteScreen"; import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen"; import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen"; import { ConnectionsNewRouteScreen } from "./features/connection/ConnectionsNewRouteScreen"; @@ -264,6 +266,8 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "GitCommit", "GitConfirm", "GitOverview", + "VersionControl", + "VersionControlDiff", "NewTaskSheet", "SettingsLegal", "SettingsSheet", @@ -438,6 +442,22 @@ export const RootStack = createNativeStackNavigator({ sheetGrabberVisible: true, }, }), + VersionControl: createNativeStackScreen({ + screen: VersionControlRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/version-control`, + options: { + ...GLASS_HEADER_OPTIONS, + presentation: "fullScreenModal", + title: "Version Control", + }, + }), + VersionControlDiff: createNativeStackScreen({ + screen: VersionControlDiffRouteScreen, + options: { + ...SOLID_HEADER_OPTIONS, + title: "Diff", + }, + }), GitCommit: createNativeStackScreen({ screen: GitCommitSheet, linking: `${THREAD_LINKING_PREFIX}/git/commit`, diff --git a/apps/mobile/src/features/threads/ThreadGitControls.tsx b/apps/mobile/src/features/threads/ThreadGitControls.tsx index 31b65f49353..12eb7966c1e 100644 --- a/apps/mobile/src/features/threads/ThreadGitControls.tsx +++ b/apps/mobile/src/features/threads/ThreadGitControls.tsx @@ -233,12 +233,20 @@ function useThreadGitControlModel(props: ThreadGitMenuProps) { }); }, [environmentId, props.onOpenGitInspector, navigation, threadId]); + const openVersionControl = useCallback(() => { + navigation.navigate("VersionControl", { + environmentId: String(environmentId), + threadId: String(threadId), + }); + }, [environmentId, navigation, threadId]); + return { currentBranchLabel, isRepo, openFiles, openGitInspector, openReview, + openVersionControl, quickAction, quickActionHint, quickActionIcon, @@ -344,6 +352,17 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit onPress: (): void => void model.runQuickAction(), type: "action", }, + { + description: "Actionable branches, stashes, and remotes", + disabled: !model.isRepo, + icon: { + name: "point.topleft.down.curvedto.point.bottomright.up", + type: "sfSymbol", + }, + label: "Version Control", + onPress: model.openVersionControl, + type: "action", + }, { description: "Turn diffs and worktree changes", disabled: !model.isRepo, @@ -373,6 +392,7 @@ function useThreadGitHeaderActionItems(props: ThreadGitControlsProps): ThreadGit model.openFiles, model.openGitInspector, model.openReview, + model.openVersionControl, model.quickAction.disabled, model.quickAction.label, model.quickActionHint, @@ -522,6 +542,14 @@ export function ThreadGitMenu(props: ThreadGitMenuProps) { > {model.quickAction.label} + + Version Control + { - void gitActions.onCheckoutSelectedThreadBranch(branch.name).then(() => { - navigation.goBack(); + void gitActions.onCheckoutSelectedThreadBranch(branch.name).then((result) => { + if (result !== null) navigation.goBack(); }); }} > diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index 17e4de0ab6f..95632334ddd 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -257,6 +257,19 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { ) : null} + + navigation.navigate("VersionControl", { + environmentId: String(environmentId), + threadId: String(threadId), + }) + } + /> + = []; + +type VersionControlDiffRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: NonNullable; +}>; + +type DiffState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly patch: string } + | { readonly status: "error"; readonly message: string }; + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) return error.message; + return "The file diff could not be loaded."; +} + +function RawPatchFallback(props: { readonly patch: string; readonly reason?: string }) { + return ( + + {props.reason ? ( + {props.reason} + ) : null} + + + {props.patch || "No textual diff available."} + + + + ); +} + +export function VersionControlDiffRouteScreen(props: VersionControlDiffRouteScreenProps) { + const { cwd, file, source } = props.route.params; + const navigation = useNavigation(); + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const api = useVersionControlPanelApi(environmentId); + const [state, setState] = useState({ status: "loading" }); + const colorScheme = useColorScheme(); + const scheme = colorScheme === "dark" ? "dark" : "light"; + const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + void retryInterruptedVersionControlRequest(() => + api.readFileDiff({ + cwd, + path: file.path, + ...(file.originalPath ? { originalPath: file.originalPath } : {}), + source, + }), + ) + .then((result) => { + if (!cancelled) setState({ status: "loaded", patch: result.patch }); + }) + .catch((cause) => { + if (!cancelled) { + setState({ status: "error", message: errorMessage(cause) }); + } + }); + return () => { + cancelled = true; + }; + }, [api, cwd, file.originalPath, file.path, source]); + + const patch = state.status === "loaded" ? state.patch : ""; + const cacheKey = `${cwd}:${file.path}:${JSON.stringify(source)}`; + const parsedDiff = useMemo(() => buildReviewParsedDiff(patch, cacheKey), [cacheKey, patch]); + const nativeData = useMemo( + () => getCachedNativeReviewDiffData({ parsedDiff, comments: EMPTY_COMMENTS }), + [parsedDiff], + ); + const NativeReviewDiffView = resolveNativeReviewDiffView(); + const bridge = useNativeReviewDiffBridge({ + threadKey: "version-control", + sectionId: cacheKey, + diff: patch, + data: nativeData, + scheme, + collapsedFileIds: EMPTY_IDS, + viewedFileIds: EMPTY_IDS, + selectedRowIds: EMPTY_IDS, + canHighlight: NativeReviewDiffView !== null && parsedDiff.kind === "files", + }); + + return ( + <> + + + navigation.goBack()} + separateBackground + /> + + {state.status === "loading" ? ( + + + + ) : state.status === "error" ? ( + + + + ) : parsedDiff.kind === "files" && NativeReviewDiffView ? ( + + + + ) : ( + + )} + + ); +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx b/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx new file mode 100644 index 00000000000..74f0bfc30c6 --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteComponents.tsx @@ -0,0 +1,316 @@ +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelSnapshotResult, +} from "@t3tools/contracts"; +import { Modal, Pressable, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { fileStatusLetter, panelChangeSets, selectedFileStats } from "./versionControlModel"; + +export function ActionButton(props: { + readonly label: string; + readonly icon: React.ComponentProps["name"]; + readonly disabled?: boolean; + readonly danger?: boolean; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor(props.danger ? "--color-danger-foreground" : "--color-icon"); + return ( + + + + {props.label} + + + ); +} + +export interface PublishRequest { + readonly branchName: string; + readonly targetCwd: string; +} + +export function PublishRemoteDialog(props: { + readonly request: PublishRequest | null; + readonly remoteNames: readonly string[]; + readonly disabled: boolean; + readonly onCancel: () => void; + readonly onSelect: (remoteName: string) => void; +}) { + const pressedOverlay = useThemeColor("--color-subtle"); + return ( + + {props.request ? ( + + + Publish branch + + Choose a remote for {props.request.branchName}. + + + {props.remoteNames.map((remoteName) => ( + + props.onSelect(remoteName)} + > + {remoteName} + + + ))} + + + Cancel + + + + ) : null} + + ); +} + +export function ChangeCounts(props: { readonly insertions: number; readonly deletions: number }) { + if (props.insertions === 0 && props.deletions === 0) return null; + return ( + + {props.insertions > 0 ? ( + +{props.insertions} + ) : null} + {props.deletions > 0 ? ( + -{props.deletions} + ) : null} + + ); +} + +export function SectionHeader(props: { + readonly title: string; + readonly subtitle?: string | null; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly action?: React.ReactNode; +}) { + const iconColor = useThemeColor("--color-icon-subtle"); + return ( + + + + + {props.title} + + {props.subtitle ? ( + {props.subtitle} + ) : null} + + {props.action} + + ); +} + +export function FileRow(props: { + readonly file: VcsPanelFileChange; + readonly selected?: boolean; + readonly disabled?: boolean; + readonly onSelect?: () => void; + readonly onOpenDiff: () => void; +}) { + const iconColor = useThemeColor("--color-icon-subtle"); + return ( + + + {props.onSelect ? ( + + + + ) : null} + + + {fileStatusLetter(props.file.status)} + + + + {props.file.path} + + {props.file.originalPath ? ( + + from {props.file.originalPath} + + ) : null} + + + + + + + ); +} + +export function BranchCommitRow(props: { + readonly commit: VcsPanelCommitSummary; + readonly direction: "ahead" | "behind"; + readonly expanded: boolean; + readonly onToggle: () => void; + readonly children: React.ReactNode; +}) { + const iconColor = useThemeColor("--color-icon-subtle"); + const stats = selectedFileStats(props.commit.files); + return ( + + + + + + {props.commit.message} + + + {props.direction === "ahead" ? "↑" : "↓"} + + + + + {props.commit.authorName ?? "Unknown author"} · {props.commit.shortSha} + + + + + + + {props.expanded ? ( + props.commit.files.length > 0 ? ( + props.children + ) : ( + + No changed files. + + ) + ) : null} + + ); +} + +export function CompactTag(props: { readonly label: string }) { + return ( + + {props.label} + + ); +} + +export function RepositorySummary(props: { readonly snapshot: VcsPanelSnapshotResult }) { + const status = props.snapshot.status; + const files = panelChangeSets(props.snapshot, "__summary__").find( + (changeSet) => changeSet.current, + )?.files; + const stats = selectedFileStats(files ?? []); + const fileCount = files?.length ?? 0; + return ( + + + {status.refName ?? "Detached HEAD"} + + + {status.aheadCount > 0 ? ( + ↑{status.aheadCount} + ) : null} + {status.behindCount > 0 ? ( + ↓{status.behindCount} + ) : null} + {!status.hasUpstream ? ( + No upstream + ) : null} + {status.hasWorkingTreeChanges ? ( + + + {fileCount} {fileCount === 1 ? "file" : "files"} + + + + ) : ( + Clean + )} + + + ); +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx b/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx new file mode 100644 index 00000000000..0c651b9a834 --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteScreen.tsx @@ -0,0 +1,806 @@ +import type { + VcsPanelBranchDetails, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsPanelStashDetails, + VcsPanelSnapshotResult, + VcsRef, + VcsStatusResult, +} from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { panelBranchOperationCwd, panelBranchSyncState } from "@t3tools/shared/sourceControl"; +import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Alert, RefreshControl, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { useEnvironmentQuery } from "../../state/query"; +import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useThreadSelection } from "../../state/use-thread-selection"; +import { vcsEnvironment } from "../../state/vcs"; +import { + actionableLocalBranches, + applyWorkingTreeEnrichments, + branchOwnsOperationCwd, + clearResolvedDetailError, + discardableFiles, + discardPathGroups, + operationPaths, + panelChangeSets, + reconcileSelectedPaths, + selectedFileStats, + snapshotForCwd, + snapshotIsPendingForCwd, + snapshotRequestIsCurrent, + stashIdentityKey, + workingTreeDiffIsStaged, + workingTreeEnrichmentRequests, + type VersionControlChangeSet, +} from "./versionControlModel"; +import { + VersionControlCommandInterrupted, + useVersionControlPanelApi, +} from "./useVersionControlPanelApi"; +import { retryInterruptedVersionControlRequest } from "./versionControlRequest"; +import { + BranchCommitRow, + CompactTag, + PublishRemoteDialog, + SectionHeader, + type PublishRequest, +} from "./VersionControlRouteComponents"; +import { VersionControlRouteView } from "./VersionControlRouteView"; + +type VersionControlRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +type FileDiffSource = NonNullable; + +interface FileDiffRequest { + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: FileDiffSource; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) return error.message; + return "The Version Control operation failed."; +} + +export function useVersionControlRouteController(props: VersionControlRouteScreenProps) { + const insets = useSafeAreaInsets(); + const subtleIconColor = useThemeColor("--color-icon-subtle"); + const navigation = useNavigation(); + const environmentId = EnvironmentId.make(props.route.params.environmentId); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitActions = useSelectedThreadGitActions(); + const api = useVersionControlPanelApi(environmentId); + const statusQuery = useEnvironmentQuery( + selectedThreadCwd + ? vcsEnvironment.status({ + environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + + const [scopedSnapshot, setScopedSnapshot] = useState<{ + readonly cwd: string; + readonly snapshot: VcsPanelSnapshotResult; + } | null>(null); + const snapshot = snapshotForCwd(scopedSnapshot, selectedThreadCwd); + const [loading, setLoading] = useState(true); + const [settledSnapshotCwd, setSettledSnapshotCwd] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [busyAction, setBusyAction] = useState(null); + const [error, setError] = useState(null); + const [mutationError, setMutationError] = useState(null); + const [actionableExpanded, setActionableExpanded] = useState(true); + const [remotesExpanded, setRemotesExpanded] = useState(false); + const [expandedRows, setExpandedRows] = useState>( + () => new Set(selectedThreadCwd ? [`changes:${selectedThreadCwd}`] : []), + ); + const expandedRowsRef = useRef>(expandedRows); + const [selectedByCwd, setSelectedByCwd] = useState>>( + new Map(), + ); + const knownPathsByCwd = useRef(new Map>()); + const initializedChangeSetCwds = useRef(new Set()); + const [branchDetails, setBranchDetails] = useState>( + new Map(), + ); + const [stashDetails, setStashDetails] = useState>( + new Map(), + ); + const [detailErrors, setDetailErrors] = useState>(new Map()); + const [showAddRemote, setShowAddRemote] = useState(false); + const [remoteName, setRemoteName] = useState(""); + const [remoteUrl, setRemoteUrl] = useState(""); + const [publishRequest, setPublishRequest] = useState(null); + const initiallyFetchedCwds = useRef(new Set()); + const snapshotRequestId = useRef(0); + const selectedThreadCwdRef = useRef(selectedThreadCwd); + const snapshotRevision = useRef(0); + const snapshotFingerprint = useRef(null); + + useLayoutEffect(() => { + selectedThreadCwdRef.current = selectedThreadCwd; + }, [selectedThreadCwd]); + + useEffect(() => { + expandedRowsRef.current = expandedRows; + }, [expandedRows]); + + const syncSelections = useCallback((nextSnapshot: VcsPanelSnapshotResult, cwd: string) => { + const changeSets = panelChangeSets(nextSnapshot, cwd); + const newlyInitializedCurrentCwds = changeSets.flatMap((changeSet) => { + if (initializedChangeSetCwds.current.has(changeSet.cwd)) return []; + initializedChangeSetCwds.current.add(changeSet.cwd); + return changeSet.current ? [changeSet.cwd] : []; + }); + const previousKnownPaths = knownPathsByCwd.current; + const nextKnownPaths = new Map( + changeSets.map( + (changeSet) => [changeSet.cwd, new Set(changeSet.files.map((file) => file.path))] as const, + ), + ); + knownPathsByCwd.current = nextKnownPaths; + + setExpandedRows((current) => { + const next = new Set(current); + for (const changeSetCwd of newlyInitializedCurrentCwds) next.add(`changes:${changeSetCwd}`); + return next; + }); + setSelectedByCwd((current) => + reconcileSelectedPaths({ + changeSets, + previousKnownPaths, + selectedByCwd: current, + }), + ); + }, []); + + const refreshSnapshot = useCallback( + async ( + options: { + readonly pull?: boolean; + readonly refresh?: "full" | "working-tree"; + } = {}, + ) => { + const requestCwd = selectedThreadCwd; + if (requestCwd !== selectedThreadCwdRef.current) return; + const requestId = ++snapshotRequestId.current; + setRefreshing(options.pull === true); + if (!requestCwd) { + if (requestId === snapshotRequestId.current) { + setSettledSnapshotCwd(null); + setLoading(false); + setRefreshing(false); + } + return; + } + setSettledSnapshotCwd((current) => (current === requestCwd ? null : current)); + try { + const rawSnapshot = await api.snapshot({ + cwd: requestCwd, + refresh: options.refresh ?? "full", + }); + if ( + !snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) + ) { + return; + } + const enrichmentResults = await Promise.allSettled( + workingTreeEnrichmentRequests(rawSnapshot, requestCwd).map( + async (request) => [request.cwd, await api.enrichWorkingTreeFiles(request)] as const, + ), + ); + const enrichmentEntries = enrichmentResults.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + if ( + !snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) + ) { + return; + } + const next = applyWorkingTreeEnrichments( + rawSnapshot, + requestCwd, + new Map(enrichmentEntries), + ); + const nextFingerprint = `${requestCwd}\0${JSON.stringify(next)}`; + if (snapshotFingerprint.current !== nextFingerprint) { + snapshotFingerprint.current = nextFingerprint; + snapshotRevision.current += 1; + setBranchDetails(new Map()); + setStashDetails(new Map()); + setDetailErrors(new Map()); + setExpandedRows( + (current) => + new Set( + [...current].filter( + (key) => + !key.startsWith("branch:") && + !key.startsWith("fork:") && + !key.startsWith("commit:") && + !key.startsWith("stash:"), + ), + ), + ); + } + setScopedSnapshot({ cwd: requestCwd, snapshot: next }); + syncSelections(next, requestCwd); + setError(null); + } catch (cause) { + if ( + snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) && + !(cause instanceof VersionControlCommandInterrupted) + ) { + setError(errorMessage(cause)); + } + } finally { + if ( + snapshotRequestIsCurrent( + requestId, + snapshotRequestId.current, + requestCwd, + selectedThreadCwdRef.current, + ) + ) { + setSettledSnapshotCwd(requestCwd); + setLoading(false); + setRefreshing(false); + } + } + }, + [api, selectedThreadCwd, syncSelections], + ); + + const runAction = useCallback( + async (label: string, action: () => Promise) => { + setBusyAction(label); + setError(null); + setMutationError(null); + let succeeded = false; + let actionError: string | null = null; + try { + await action(); + succeeded = true; + } catch (cause) { + if (!(cause instanceof VersionControlCommandInterrupted)) actionError = errorMessage(cause); + } finally { + await refreshSnapshot(); + statusQuery.refresh(); + if (actionError) setMutationError(actionError); + setBusyAction(null); + } + return succeeded; + }, + [refreshSnapshot, statusQuery], + ); + + useFocusEffect( + useCallback(() => { + if (!selectedThreadCwd) return; + const cwd = selectedThreadCwd; + void refreshSnapshot(); + if (!initiallyFetchedCwds.current.has(cwd)) { + initiallyFetchedCwds.current.add(cwd); + void api + .fetchAllRemotes({ cwd }) + .then(() => refreshSnapshot()) + .catch(() => undefined); + } + }, [api, refreshSnapshot, selectedThreadCwd]), + ); + + const statusFingerprint = statusQuery.data ? JSON.stringify(statusQuery.data) : null; + const lastStatusRefresh = useRef<{ + readonly data: VcsStatusResult; + readonly fingerprint: string; + } | null>(null); + useEffect(() => { + if (!statusQuery.data || !statusFingerprint) return; + const previous = lastStatusRefresh.current; + if (previous?.data === statusQuery.data && previous.fingerprint === statusFingerprint) return; + lastStatusRefresh.current = { + data: statusQuery.data, + fingerprint: statusFingerprint, + }; + if (previous) void refreshSnapshot({ refresh: "working-tree" }); + }, [refreshSnapshot, statusFingerprint, statusQuery.data]); + + const changeSets = useMemo( + () => (snapshot && selectedThreadCwd ? panelChangeSets(snapshot, selectedThreadCwd) : []), + [selectedThreadCwd, snapshot], + ); + const localBranches = useMemo( + () => (snapshot ? actionableLocalBranches(snapshot) : []), + [snapshot], + ); + const actionCount = + changeSets.length + + localBranches.length + + (snapshot?.actionableForkBranches.length ?? 0) + + (snapshot?.stashes.length ?? 0); + const busy = busyAction !== null; + const headerToolbar = ( + + navigation.goBack()} + separateBackground + /> + + ); + + const toggleExpanded = useCallback((key: string) => { + setExpandedRows((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + expandedRowsRef.current = next; + return next; + }); + }, []); + + const openFileDiff = useCallback( + (request: FileDiffRequest) => { + navigation.navigate("VersionControlDiff", { + environmentId: String(environmentId), + cwd: request.cwd, + file: request.file, + source: request.source, + }); + }, + [environmentId, navigation], + ); + + const toggleSelectedFile = useCallback((cwd: string, path: string) => { + setSelectedByCwd((current) => { + const next = new Map(current); + const selected = new Set(next.get(cwd) ?? []); + if (selected.has(path)) selected.delete(path); + else selected.add(path); + next.set(cwd, selected); + return next; + }); + }, []); + + const selectAllFiles = useCallback((changeSet: VersionControlChangeSet) => { + setSelectedByCwd((current) => { + const next = new Map(current); + const selected = next.get(changeSet.cwd) ?? new Set(); + next.set( + changeSet.cwd, + selected.size === changeSet.files.length + ? new Set() + : new Set(changeSet.files.map((file) => file.path)), + ); + return next; + }); + }, []); + + const selectedFiles = useCallback( + (changeSet: VersionControlChangeSet) => { + const selected = selectedByCwd.get(changeSet.cwd) ?? new Set(); + return changeSet.files.filter((file) => selected.has(file.path)); + }, + [selectedByCwd], + ); + + const commitSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = selectedFiles(changeSet); + const paths = operationPaths(files); + if (paths.length === 0) return; + void runAction("commit", async () => { + await api.commitStaged({ cwd: changeSet.cwd, paths }); + }); + }, + [api, runAction, selectedFiles], + ); + + const stashSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = selectedFiles(changeSet); + const paths = operationPaths(files); + if (paths.length === 0) return; + void runAction("stash", () => + api.createStash({ cwd: changeSet.cwd, paths, includeUntracked: true }), + ); + }, + [api, runAction, selectedFiles], + ); + + const discardSelected = useCallback( + (changeSet: VersionControlChangeSet) => { + const files = discardableFiles(selectedFiles(changeSet)); + const paths = discardPathGroups(files); + if (paths.staged.length === 0 && paths.unstaged.length === 0) return; + Alert.alert( + "Discard selected changes?", + `This permanently discards changes in ${files.length} selected file${files.length === 1 ? "" : "s"}.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Discard", + style: "destructive", + onPress: () => + void runAction("discard", async () => { + if (paths.unstaged.length > 0) { + await api.discardFiles({ + cwd: changeSet.cwd, + paths: paths.unstaged, + }); + } + if (paths.staged.length > 0) { + await api.discardFiles({ + cwd: changeSet.cwd, + paths: paths.staged, + staged: true, + }); + } + }), + }, + ], + ); + }, + [api, runAction, selectedFiles], + ); + + const loadBranchDetails = useCallback( + (branch: VcsRef, key: string, compareBaseRef?: string) => { + const wasExpanded = expandedRowsRef.current.has(key); + toggleExpanded(key); + if (!snapshot || branchDetails.has(key) || wasExpanded) return; + const previousDetailError = detailErrors.get(key) ?? null; + const revision = snapshotRevision.current; + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + void retryInterruptedVersionControlRequest(() => + api.branchDetails({ + cwd: selectedThreadCwd ?? "", + branch, + defaultCompareRef: snapshot.defaultCompareRef, + ...(compareBaseRef ? { compareBaseRef } : {}), + }), + ) + .then((details) => { + if (revision !== snapshotRevision.current) return; + setBranchDetails((current) => new Map(current).set(key, details)); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + setError((current) => clearResolvedDetailError(current, previousDetailError)); + }) + .catch((cause) => { + if ( + revision === snapshotRevision.current && + !(cause instanceof VersionControlCommandInterrupted) + ) { + const message = errorMessage(cause); + setDetailErrors((current) => new Map(current).set(key, message)); + setError(message); + } + }); + }, + [api, branchDetails, detailErrors, selectedThreadCwd, snapshot, toggleExpanded], + ); + + const publishBranch = useCallback( + (branch: VcsRef, targetCwd: string) => { + if (!snapshot) return; + if (snapshot.remotes.length === 0) { + setError("Add a remote before publishing this branch."); + return; + } + if (snapshot.remotes.length > 1) { + setPublishRequest({ branchName: branch.name, targetCwd }); + return; + } + const remote = snapshot.remotes[0]; + if (!remote) return; + void runAction("publish", () => + api.pushBranch({ + cwd: targetCwd, + branchName: branch.name, + remoteName: remote.name, + }), + ); + }, + [api, runAction, snapshot], + ); + + const publishToRemote = useCallback( + (remoteName: string) => { + const request = publishRequest; + if (!request) return; + setPublishRequest(null); + void runAction("publish", () => + api.pushBranch({ + cwd: request.targetCwd, + branchName: request.branchName, + remoteName, + }), + ); + }, + [api, publishRequest, runAction], + ); + + const syncBranch = useCallback( + (branch: VcsRef) => { + if (!snapshot || !selectedThreadCwd) return; + const state = panelBranchSyncState(branch, snapshot); + const targetCwd = panelBranchOperationCwd(branch, selectedThreadCwd); + if (state === "publish") { + publishBranch(branch, targetCwd); + return; + } + if (state === "push") { + void runAction("push", () => api.pushBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + if (state === "pull") { + void runAction("pull", () => api.pullBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + if (state === "fetch") { + void runAction("fetch", () => api.fetchBranch({ cwd: targetCwd, branchName: branch.name })); + return; + } + const canMerge = branchOwnsOperationCwd(branch); + Alert.alert("Branch has diverged", "Choose how to synchronize this branch.", [ + { text: "Cancel", style: "cancel" }, + ...(canMerge + ? [ + { + text: "Pull & merge", + onPress: () => + void runAction("merge-sync", () => + api.pullBranch({ + cwd: targetCwd, + branchName: branch.name, + merge: true, + }), + ), + }, + ] + : []), + { + text: "More…", + onPress: () => + Alert.alert("Destructive sync", "These actions overwrite one side of the branch.", [ + { text: "Cancel", style: "cancel" }, + { + text: "Force pull", + style: "destructive", + onPress: () => + void runAction("force-pull", () => + api.pullBranch({ + cwd: targetCwd, + branchName: branch.name, + force: true, + }), + ), + }, + { + text: "Force push", + style: "destructive", + onPress: () => + void runAction("force-push", () => + api.pushBranch({ + cwd: targetCwd, + branchName: branch.name, + force: true, + }), + ), + }, + ]), + }, + ]); + }, + [api, publishBranch, runAction, selectedThreadCwd, snapshot], + ); + + const switchBranch = useCallback( + (branch: VcsRef) => { + void runAction("switch", async () => { + await gitActions.onCheckoutSelectedThreadBranch(branch.name, { + throwOnFailure: true, + }); + }); + }, + [gitActions, runAction], + ); + + const deleteBranch = useCallback( + (branch: VcsRef) => { + if (!selectedThreadCwd || branch.current || branch.worktreePath !== null) return; + Alert.alert("Delete branch?", `Delete ${branch.name}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => + void runAction("delete-branch", () => + api.deleteBranch({ + cwd: selectedThreadCwd, + branchName: branch.name, + }), + ), + }, + ]); + }, + [api, runAction, selectedThreadCwd], + ); + + const mergeBranch = useCallback( + (refName: string) => { + if (!selectedThreadCwd) return; + Alert.alert("Merge branch?", `Merge ${refName} into the current branch?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Merge", + onPress: () => + void runAction("merge-branch", () => + api.mergeBranchIntoCurrent({ cwd: selectedThreadCwd, refName }), + ), + }, + ]); + }, + [api, runAction, selectedThreadCwd], + ); + + const rebaseBranch = useCallback( + (refName: string) => { + if (!selectedThreadCwd) return; + Alert.alert("Rebase branch?", `Rebase the current branch onto ${refName}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Rebase", + onPress: () => + void runAction("rebase-branch", () => + api.rebaseCurrentOnto({ cwd: selectedThreadCwd, refName }), + ), + }, + ]); + }, + [api, runAction, selectedThreadCwd], + ); + + const loadStashDetails = useCallback( + (stash: VcsPanelSnapshotResult["stashes"][number]) => { + const detailsKey = stashIdentityKey(stash); + const key = `stash:${detailsKey}`; + const wasExpanded = expandedRowsRef.current.has(key); + toggleExpanded(key); + if (!selectedThreadCwd || stashDetails.has(detailsKey) || wasExpanded) return; + const previousDetailError = detailErrors.get(key) ?? null; + const revision = snapshotRevision.current; + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + void retryInterruptedVersionControlRequest(() => + api.stashDetails({ cwd: selectedThreadCwd, stashRef: stash.refName }), + ) + .then((details) => { + if (revision !== snapshotRevision.current) return; + setStashDetails((current) => new Map(current).set(detailsKey, details)); + setDetailErrors((current) => { + if (!current.has(key)) return current; + const next = new Map(current); + next.delete(key); + return next; + }); + setError((current) => clearResolvedDetailError(current, previousDetailError)); + }) + .catch((cause) => { + if ( + revision === snapshotRevision.current && + !(cause instanceof VersionControlCommandInterrupted) + ) { + const message = errorMessage(cause); + setDetailErrors((current) => new Map(current).set(key, message)); + setError(message); + } + }); + }, + [api, detailErrors, selectedThreadCwd, stashDetails, toggleExpanded], + ); + + return { + actionableExpanded, + actionCount, + api, + branchDetails, + busy, + busyAction, + changeSets, + commitSelected, + deleteBranch, + detailErrors, + discardSelected, + error, + expandedRows, + headerToolbar, + insets, + loadBranchDetails, + loadStashDetails, + loading: loading || snapshotIsPendingForCwd(snapshot, selectedThreadCwd, settledSnapshotCwd), + localBranches, + mergeBranch, + mutationError, + openFileDiff, + publishRequest, + publishToRemote, + rebaseBranch, + refreshing, + refreshSnapshot, + remoteName, + remotesExpanded, + remoteUrl, + runAction, + selectAllFiles, + selectedByCwd, + selectedFiles, + selectedThread, + selectedThreadCwd, + setActionableExpanded, + setError, + setMutationError, + setPublishRequest, + setRemoteName, + setRemotesExpanded, + setRemoteUrl, + setShowAddRemote, + showAddRemote, + snapshot, + stashDetails, + stashSelected, + subtleIconColor, + switchBranch, + syncBranch, + toggleExpanded, + toggleSelectedFile, + }; +} + +export type VersionControlRouteController = ReturnType; + +export function VersionControlRouteScreen(props: VersionControlRouteScreenProps) { + return ; +} diff --git a/apps/mobile/src/features/version-control/VersionControlRouteView.tsx b/apps/mobile/src/features/version-control/VersionControlRouteView.tsx new file mode 100644 index 00000000000..d64982c3b48 --- /dev/null +++ b/apps/mobile/src/features/version-control/VersionControlRouteView.tsx @@ -0,0 +1,953 @@ +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsRef, +} from "@t3tools/contracts"; +import { panelBranchSyncCounts, panelBranchSyncState } from "@t3tools/shared/sourceControl"; +import { Alert, Pressable, RefreshControl, ScrollView, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { EmptyState } from "../../components/EmptyState"; +import { SheetActionButton } from "../threads/git/gitSheetComponents"; +import { + branchSyncLabel, + discardableFiles, + localBranchForRemoteBranch, + relativeLabel, + selectedFileStats, + stashIdentityKey, + visibleRemoteBranches, + workingTreeDiffIsStaged, +} from "./versionControlModel"; +import { + ActionButton, + BranchCommitRow, + ChangeCounts, + CompactTag, + FileRow, + PublishRemoteDialog, + RepositorySummary, + SectionHeader, +} from "./VersionControlRouteComponents"; +import type { VersionControlRouteController } from "./VersionControlRouteScreen"; + +type FileDiffSource = NonNullable; +interface FileDiffRequest { + readonly cwd: string; + readonly file: VcsPanelFileChange; + readonly source: FileDiffSource; +} + +export function VersionControlRouteView({ + controller, +}: { + readonly controller: VersionControlRouteController; +}) { + const { + actionableExpanded, + actionCount, + api, + branchDetails, + busy, + busyAction, + changeSets, + commitSelected, + deleteBranch, + detailErrors, + discardSelected, + error, + expandedRows, + headerToolbar, + insets, + loadBranchDetails, + loadStashDetails, + loading, + localBranches, + mergeBranch, + mutationError, + openFileDiff, + publishRequest, + publishToRemote, + rebaseBranch, + refreshing, + refreshSnapshot, + remoteName, + remotesExpanded, + remoteUrl, + runAction, + selectAllFiles, + selectedByCwd, + selectedFiles, + selectedThread, + selectedThreadCwd, + setActionableExpanded, + setError, + setMutationError, + setPublishRequest, + setRemoteName, + setRemotesExpanded, + setRemoteUrl, + setShowAddRemote, + showAddRemote, + snapshot, + stashDetails, + stashSelected, + subtleIconColor, + switchBranch, + syncBranch, + toggleExpanded, + toggleSelectedFile, + } = controller; + const renderBranchCommit = ( + commit: VcsPanelCommitSummary, + direction: "ahead" | "behind", + parentKey: string, + cwd: string, + ) => { + const commitKey = `commit:${parentKey}:${commit.sha}`; + const expanded = expandedRows.has(commitKey); + return ( + toggleExpanded(commitKey)} + > + {commit.files.map((file) => { + const request: FileDiffRequest = { + cwd, + file, + source: { kind: "commit", sha: commit.sha }, + }; + return ( + openFileDiff(request)} + /> + ); + })} + + ); + }; + + if (!selectedThread || !selectedThreadCwd) { + return ( + <> + {headerToolbar} + + + + + ); + } + + if (loading && !snapshot) { + return ( + <> + {headerToolbar} + + + Loading repository state… + + + + ); + } + + if (!snapshot) { + return ( + <> + {headerToolbar} + + void refreshSnapshot()} + /> + + + ); + } + + return ( + <> + {headerToolbar} + remote.name)} + disabled={busy} + onCancel={() => setPublishRequest(null)} + onSelect={publishToRemote} + /> + void refreshSnapshot({ pull: true })} + /> + } + > + + + {(mutationError ?? error) ? ( + + + {mutationError ?? error} + + { + setMutationError(null); + setError(null); + }} + > + Dismiss + + + ) : null} + + {busyAction ? ( + + {busyAction} + + Refreshing repository state when done… + + + ) : null} + + + setActionableExpanded((value) => !value)} + action={ + + void runAction("fetch-all", () => + api.fetchAllRemotes({ cwd: selectedThreadCwd, force: true }), + ) + } + /> + } + /> + {actionableExpanded ? ( + + {changeSets.map((changeSet) => { + const rowKey = `changes:${changeSet.cwd}`; + const expanded = expandedRows.has(rowKey); + const selected = selectedFiles(changeSet); + const discardable = discardableFiles(selected); + const stats = selectedFileStats(selected); + return ( + + toggleExpanded(rowKey)} + > + + + {changeSet.current ? "Working tree" : changeSet.branchName} + + + {selected.length} of {changeSet.files.length} files selected + {changeSet.current ? "" : ` · ${changeSet.cwd}`} + + + + + {expanded ? ( + <> + + selectAllFiles(changeSet)} + /> + commitSelected(changeSet)} + /> + stashSelected(changeSet)} + /> + discardSelected(changeSet)} + /> + + {changeSet.files.map((file) => { + const diffRequest: FileDiffRequest = { + cwd: changeSet.cwd, + file, + source: { + kind: "working-tree", + staged: workingTreeDiffIsStaged(file), + }, + }; + return ( + toggleSelectedFile(changeSet.cwd, file.path)} + onOpenDiff={() => openFileDiff(diffRequest)} + /> + ); + })} + + ) : null} + + ); + })} + + {localBranches.map((branch) => { + const key = `branch:${branch.name}`; + const details = branchDetails.get(key); + const state = panelBranchSyncState(branch, snapshot); + const counts = panelBranchSyncCounts(branch, snapshot); + const date = relativeLabel(branch.lastActivityAt); + return ( + + loadBranchDetails(branch, key)} + > + + + {branch.name} + + + {branch.current + ? "Current branch" + : branch.worktreePath + ? "Checked out" + : "Local branch"} + {date ? ` · ${date}` : ""} + + + {counts.aheadCount > 0 ? ( + + ↑{counts.aheadCount} + + ) : null} + {counts.behindCount > 0 ? ( + + ↓{counts.behindCount} + + ) : null} + + {expandedRows.has(key) ? ( + + + {!branch.current && !branch.worktreePath ? ( + switchBranch(branch)} + /> + ) : null} + syncBranch(branch)} + /> + {!branch.current ? ( + <> + mergeBranch(branch.name)} + /> + rebaseBranch(branch.name)} + /> + deleteBranch(branch)} + /> + + ) : null} + + {details ? ( + <> + + + vs.{" "} + {details.baseRef ?? snapshot.defaultCompareRef ?? "default branch"} + + + {details.aheadCommits.length} ahead · {details.behindCommits.length}{" "} + behind · {details.compareFiles.length} changed + + + {details.aheadCommits.map((commit) => + renderBranchCommit(commit, "ahead", key, selectedThreadCwd), + )} + {details.behindCommits.map((commit) => + renderBranchCommit(commit, "behind", key, selectedThreadCwd), + )} + {details.aheadCommits.length === 0 && + details.behindCommits.length === 0 ? ( + + No commits differ from the comparison branch. + + ) : null} + + ) : ( + + {detailErrors.has(key) + ? `Unable to load branch details: ${detailErrors.get(key)}` + : "Loading branch details…"} + + )} + + ) : null} + + ); + })} + + {snapshot.actionableForkBranches.map((fork) => { + const branch = snapshot.localBranches.find( + (candidate) => candidate.name === fork.localBranchName, + ); + if (!branch) return null; + const key = `fork:${fork.localBranchName}:${fork.remoteRefName}`; + return ( + + loadBranchDetails(branch, key, fork.remoteRefName)} + > + + + {fork.localBranchName} + + + Behind {fork.remoteRefName} + + + + ↓{fork.behindCount} + + + {expandedRows.has(key) ? ( + + + + void runAction("fetch-fork", () => + api.fetchBranch({ + cwd: selectedThreadCwd, + branchName: fork.remoteRefName, + }), + ) + } + /> + + {branchDetails.get(key) ? ( + <> + {branchDetails + .get(key) + ?.aheadCommits.map((commit) => + renderBranchCommit(commit, "ahead", key, selectedThreadCwd), + )} + {branchDetails + .get(key) + ?.behindCommits.map((commit) => + renderBranchCommit(commit, "behind", key, selectedThreadCwd), + )} + + ) : ( + + {detailErrors.has(key) + ? `Unable to load comparison: ${detailErrors.get(key)}` + : "Loading comparison…"} + + )} + + ) : null} + + ); + })} + + {snapshot.stashes.map((stash) => { + const detailsKey = stashIdentityKey(stash); + const key = `stash:${detailsKey}`; + const details = stashDetails.get(detailsKey); + return ( + + loadStashDetails(stash)} + > + + + {stash.message} + + + {stash.refName} + {relativeLabel(stash.createdAt) + ? ` · ${relativeLabel(stash.createdAt)}` + : ""} + + + + {expandedRows.has(key) ? ( + + + + void runAction("apply-stash", () => + api.applyStash({ + cwd: selectedThreadCwd, + stashRef: stash.refName, + }), + ) + } + /> + + void runAction("pop-stash", () => + api.popStash({ + cwd: selectedThreadCwd, + stashRef: stash.refName, + }), + ) + } + /> + + Alert.alert("Drop stash?", `Permanently drop ${stash.refName}?`, [ + { text: "Cancel", style: "cancel" }, + { + text: "Drop", + style: "destructive", + onPress: () => + void runAction("drop-stash", () => + api.dropStash({ + cwd: selectedThreadCwd, + stashRef: stash.refName, + }), + ), + }, + ]) + } + /> + + {details ? ( + details.files.map((file) => { + const request: FileDiffRequest = { + cwd: selectedThreadCwd, + file, + source: { + kind: "stash", + stashRef: stash.refName, + }, + }; + return ( + openFileDiff(request)} + /> + ); + }) + ) : ( + + {detailErrors.has(key) + ? `Unable to load stash details: ${detailErrors.get(key)}` + : "Loading stash details…"} + + )} + + ) : null} + + ); + })} + + {actionCount === 0 ? ( + + + Nothing needs attention + + + The working tree and tracked branches are synchronized. + + + ) : null} + + ) : null} + + + + setRemotesExpanded((value) => !value)} + action={ + { + setRemotesExpanded(true); + setShowAddRemote((value) => !value); + }} + /> + } + /> + {remotesExpanded ? ( + + {showAddRemote ? ( + + + + + void runAction("add-remote", () => + api.addRemote({ + cwd: selectedThreadCwd, + name: remoteName.trim(), + url: remoteUrl.trim(), + }), + ).then((succeeded) => { + if (!succeeded) return; + setRemoteName(""); + setRemoteUrl(""); + setShowAddRemote(false); + }) + } + /> + + ) : null} + {snapshot.remotes.map((remote) => { + const key = `remote:${remote.name}`; + const remoteExpanded = expandedRows.has(key); + return ( + + toggleExpanded(key)} + > + + + + + {remote.name} + + + + {remote.fetchUrl ?? "No fetch URL"} · {remote.branches.length} branches + + + + {remoteExpanded ? ( + + + void runAction("fetch-remote", () => + api.fetchRemote({ + cwd: selectedThreadCwd, + remoteName: remote.name, + }), + ) + } + /> + + Alert.alert( + "Remove remote?", + `Remove ${remote.name} from this repository?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => + void runAction("remove-remote", () => + api.removeRemote({ + cwd: selectedThreadCwd, + remoteName: remote.name, + }), + ), + }, + ], + ) + } + /> + + ) : null} + {visibleRemoteBranches(remote, remoteExpanded).map((remoteBranch) => { + const localBranch = localBranchForRemoteBranch( + snapshot, + remote, + remoteBranch, + ); + const branch: VcsRef = localBranch ?? { + name: remoteBranch.fullRefName, + isRemote: true, + remoteName: remote.name, + current: false, + isDefault: remoteBranch.isDefaultRemoteHead, + worktreePath: null, + lastActivityAt: remoteBranch.lastActivityAt ?? null, + }; + const branchKey = `remote-branch:${remote.name}:${remoteBranch.name}`; + const branchExpanded = expandedRows.has(branchKey); + const counts = localBranch + ? panelBranchSyncCounts(localBranch, snapshot) + : { aheadCount: 0, behindCount: 0 }; + const syncState = localBranch + ? panelBranchSyncState(localBranch, snapshot) + : null; + return ( + + toggleExpanded(branchKey)} + > + + + + {remoteBranch.name} + + + + {localBranch?.current ? : null} + {localBranch?.worktreePath && !localBranch.current ? ( + + ) : null} + {remoteBranch.isDefaultRemoteHead || localBranch?.isDefault ? ( + + ) : null} + + + {counts.aheadCount > 0 ? ( + + ↑{counts.aheadCount} + + ) : null} + {counts.behindCount > 0 ? ( + + ↓{counts.behindCount} + + ) : null} + + {branchExpanded ? ( + + {!branch.current && !branch.worktreePath ? ( + switchBranch(branch)} + /> + ) : null} + {localBranch && syncState ? ( + syncBranch(localBranch)} + /> + ) : ( + + void runAction("fetch-remote-branch", () => + api.fetchBranch({ + cwd: selectedThreadCwd, + branchName: remoteBranch.fullRefName, + }), + ) + } + /> + )} + {!branch.current ? ( + <> + mergeBranch(branch.name)} + /> + rebaseBranch(branch.name)} + /> + deleteBranch(branch)} + /> + + ) : null} + + ) : null} + + ); + })} + + ); + })} + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts b/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts new file mode 100644 index 00000000000..d15f3dcf2ab --- /dev/null +++ b/apps/mobile/src/features/version-control/useVersionControlPanelApi.ts @@ -0,0 +1,196 @@ +import type { + EnvironmentId, + VcsPanelBranchDetails, + VcsPanelFileDiffResult, + VcsPanelSnapshotInput, + VcsPanelSnapshotResult, + VcsPanelStashDetails, + VcsPanelWorkingTreeFileEnrichmentResult, +} from "@t3tools/contracts"; +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useMemo } from "react"; + +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { vcsEnvironment } from "../../state/vcs"; +import { VersionControlCommandInterrupted } from "./versionControlRequest"; + +export { VersionControlCommandInterrupted } from "./versionControlRequest"; + +async function unwrapPanelCommand( + result: AtomCommandResult, +): Promise { + if (result._tag === "Success") return result.value; + if (isAtomCommandInterrupted(result)) throw new VersionControlCommandInterrupted(); + throw squashAtomCommandFailure(result); +} + +export function useVersionControlPanelApi(environmentId: EnvironmentId) { + const panelSnapshot = useAtomQueryRunner(vcsEnvironment.panelSnapshot, { + forceRefresh: true, + reportFailure: false, + }); + const panelBranchDetails = useAtomQueryRunner(vcsEnvironment.panelBranchDetails, { + forceRefresh: true, + reportFailure: false, + }); + const panelStashDetails = useAtomQueryRunner(vcsEnvironment.panelStashDetails, { + forceRefresh: true, + reportFailure: false, + }); + const panelReadFileDiff = useAtomQueryRunner(vcsEnvironment.panelReadFileDiff, { + forceRefresh: true, + reportFailure: false, + }); + const panelEnrichWorkingTreeFiles = useAtomQueryRunner( + vcsEnvironment.panelEnrichWorkingTreeFiles, + { + forceRefresh: true, + reportFailure: false, + }, + ); + + const panelStageFiles = useAtomCommand(vcsEnvironment.panelStageFiles, { + reportFailure: false, + }); + const panelDiscardFiles = useAtomCommand(vcsEnvironment.panelDiscardFiles, { + reportFailure: false, + }); + const panelCommitStaged = useAtomCommand(vcsEnvironment.panelCommitStaged, { + reportFailure: false, + }); + const panelPullBranch = useAtomCommand(vcsEnvironment.panelPullBranch, { + reportFailure: false, + }); + const panelPushBranch = useAtomCommand(vcsEnvironment.panelPushBranch, { + reportFailure: false, + }); + const panelDeleteBranch = useAtomCommand(vcsEnvironment.panelDeleteBranch, { + reportFailure: false, + }); + const panelMergeBranchIntoCurrent = useAtomCommand(vcsEnvironment.panelMergeBranchIntoCurrent, { + reportFailure: false, + }); + const panelRebaseCurrentOnto = useAtomCommand(vcsEnvironment.panelRebaseCurrentOnto, { + reportFailure: false, + }); + const panelFetchBranch = useAtomCommand(vcsEnvironment.panelFetchBranch, { + reportFailure: false, + }); + const panelFetchRemote = useAtomCommand(vcsEnvironment.panelFetchRemote, { + reportFailure: false, + }); + const panelFetchAllRemotes = useAtomCommand(vcsEnvironment.panelFetchAllRemotes, { + reportFailure: false, + }); + const panelAddRemote = useAtomCommand(vcsEnvironment.panelAddRemote, { + reportFailure: false, + }); + const panelRemoveRemote = useAtomCommand(vcsEnvironment.panelRemoveRemote, { + reportFailure: false, + }); + const panelCreateStash = useAtomCommand(vcsEnvironment.panelCreateStash, { + reportFailure: false, + }); + const panelApplyStash = useAtomCommand(vcsEnvironment.panelApplyStash, { + reportFailure: false, + }); + const panelPopStash = useAtomCommand(vcsEnvironment.panelPopStash, { + reportFailure: false, + }); + const panelDropStash = useAtomCommand(vcsEnvironment.panelDropStash, { + reportFailure: false, + }); + + const runPanelCommand = useCallback( + async ( + command: (target: { + readonly environmentId: EnvironmentId; + readonly input: TInput; + }) => Promise>, + input: TInput, + ): Promise => unwrapPanelCommand(await command({ environmentId, input })), + [environmentId], + ); + + return useMemo( + () => ({ + snapshot: (input: VcsPanelSnapshotInput) => + runPanelCommand(panelSnapshot, input), + branchDetails: (input: Parameters[0]["input"]) => + runPanelCommand(panelBranchDetails, input), + stashDetails: (input: Parameters[0]["input"]) => + runPanelCommand(panelStashDetails, input), + readFileDiff: (input: Parameters[0]["input"]) => + runPanelCommand(panelReadFileDiff, input), + enrichWorkingTreeFiles: (input: Parameters[0]["input"]) => + runPanelCommand( + panelEnrichWorkingTreeFiles, + input, + ), + stageFiles: (input: Parameters[0]["input"]) => + runPanelCommand(panelStageFiles, input), + discardFiles: (input: Parameters[0]["input"]) => + runPanelCommand(panelDiscardFiles, input), + commitStaged: (input: Parameters[0]["input"]) => + runPanelCommand(panelCommitStaged, input), + pullBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelPullBranch, input), + pushBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelPushBranch, input), + deleteBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelDeleteBranch, input), + mergeBranchIntoCurrent: (input: Parameters[0]["input"]) => + runPanelCommand(panelMergeBranchIntoCurrent, input), + rebaseCurrentOnto: (input: Parameters[0]["input"]) => + runPanelCommand(panelRebaseCurrentOnto, input), + fetchBranch: (input: Parameters[0]["input"]) => + runPanelCommand(panelFetchBranch, input), + fetchRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelFetchRemote, input), + fetchAllRemotes: (input: Parameters[0]["input"]) => + runPanelCommand(panelFetchAllRemotes, input), + addRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelAddRemote, input), + removeRemote: (input: Parameters[0]["input"]) => + runPanelCommand(panelRemoveRemote, input), + createStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelCreateStash, input), + applyStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelApplyStash, input), + popStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelPopStash, input), + dropStash: (input: Parameters[0]["input"]) => + runPanelCommand(panelDropStash, input), + }), + [ + panelAddRemote, + panelApplyStash, + panelBranchDetails, + panelCommitStaged, + panelCreateStash, + panelDeleteBranch, + panelDiscardFiles, + panelDropStash, + panelEnrichWorkingTreeFiles, + panelFetchAllRemotes, + panelFetchBranch, + panelFetchRemote, + panelMergeBranchIntoCurrent, + panelPopStash, + panelPullBranch, + panelPushBranch, + panelReadFileDiff, + panelRebaseCurrentOnto, + panelRemoveRemote, + panelSnapshot, + panelStageFiles, + panelStashDetails, + runPanelCommand, + ], + ); +} diff --git a/apps/mobile/src/features/version-control/versionControlModel.test.ts b/apps/mobile/src/features/version-control/versionControlModel.test.ts new file mode 100644 index 00000000000..d261e08d51b --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlModel.test.ts @@ -0,0 +1,380 @@ +import type { VcsPanelSnapshotResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + actionableLocalBranches, + applyWorkingTreeEnrichments, + branchOwnsOperationCwd, + clearResolvedDetailError, + discardableFiles, + discardPathGroups, + localBranchForRemoteBranch, + operationPaths, + panelChangeSets, + reconcileSelectedPaths, + relativeLabel, + selectedFileStats, + snapshotForCwd, + snapshotIsPendingForCwd, + snapshotRequestIsCurrent, + stashIdentityKey, + visibleRemoteBranches, + workingTreeDiffIsStaged, + workingTreeEnrichmentRequests, +} from "./versionControlModel"; + +function snapshot(): VcsPanelSnapshotResult { + return { + status: { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/mobile-vcs", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 0, + pr: null, + }, + changeGroups: [ + { + kind: "staged", + files: [ + { + path: "src/a.ts", + originalPath: null, + status: "modified", + insertions: 2, + deletions: 1, + }, + ], + }, + { + kind: "unstaged", + files: [ + { + path: "src/a.ts", + originalPath: null, + status: "modified", + insertions: 3, + deletions: 4, + }, + ], + }, + ], + worktreeChangeSets: [], + localBranches: [ + { + name: "feature/mobile-vcs", + current: true, + isDefault: false, + worktreePath: null, + upstreamName: "origin/feature/mobile-vcs", + }, + { + name: "local-only", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: null, + }, + { + name: "synced", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "origin/synced", + aheadCount: 0, + behindCount: 0, + }, + ], + branchDetails: [], + remotes: [{ name: "origin", fetchUrl: null, pushUrl: null, provider: null, branches: [] }], + actionableForkBranches: [], + stashes: [], + recentCommits: [], + defaultCompareRef: "main", + }; +} + +describe("native Version Control model", () => { + it("merges the current working tree and preserves aggregate selected stats", () => { + const [changeSet] = panelChangeSets(snapshot(), "/repo"); + expect(changeSet?.files).toEqual([ + expect.objectContaining({ + path: "src/a.ts", + insertions: 5, + deletions: 5, + hasStagedChanges: true, + hasUnstagedChanges: true, + }), + ]); + expect(selectedFileStats(changeSet?.files ?? [])).toEqual({ insertions: 5, deletions: 5 }); + }); + + it("shows only branches that need action", () => { + expect(actionableLocalBranches(snapshot()).map((branch) => branch.name)).toEqual([ + "feature/mobile-vcs", + "local-only", + ]); + }); + + it("keeps stash identity stable when positional refs are renumbered", () => { + expect(stashIdentityKey({ refName: "stash@{2}", sha: "abc123" })).toBe("sha:abc123"); + expect(stashIdentityKey({ refName: "stash@{0}", sha: "abc123" })).toBe("sha:abc123"); + expect(stashIdentityKey({ refName: "stash@{0}", sha: null })).toBe("ref:stash@{0}"); + }); + + it("omits missing and invalid relative dates", () => { + expect(relativeLabel(null)).toBeNull(); + expect(relativeLabel("")).toBeNull(); + expect(relativeLabel("not-a-timestamp")).toBeNull(); + expect(relativeLabel(new Date().toISOString())).toBe("<1m"); + }); + + it("matches remote rows only to locals tracking that exact remote ref", () => { + const current = snapshot(); + const localBranches = [ + { + name: "release", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "upstream/release", + }, + { + name: "release", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: "origin/release", + }, + { + name: "untracked", + current: false, + isDefault: false, + worktreePath: null, + upstreamName: null, + }, + ]; + const remoteBranch = { + name: "release", + fullRefName: "origin/release", + isDefaultRemoteHead: false, + }; + + expect( + localBranchForRemoteBranch({ ...current, localBranches }, { name: "origin" }, remoteBranch), + ).toBe(localBranches[1]); + expect( + localBranchForRemoteBranch( + { ...current, localBranches: [localBranches[0]!] }, + { name: "origin" }, + remoteBranch, + ), + ).toBeNull(); + }); + + it("shows a remote's branches only while that remote is expanded", () => { + const remote = { + name: "origin", + fetchUrl: null, + pushUrl: null, + provider: null, + branches: [ + { + name: "main", + fullRefName: "origin/main", + isDefaultRemoteHead: true, + }, + ], + }; + + expect(visibleRemoteBranches(remote, false)).toEqual([]); + expect(visibleRemoteBranches(remote, true)).toBe(remote.branches); + }); + + it("includes both rename sides once in operation paths", () => { + expect( + operationPaths([ + { path: "new.ts", originalPath: "old.ts", status: "renamed" }, + { path: "new.ts", originalPath: "old.ts", status: "renamed" }, + ]), + ).toEqual(["new.ts", "old.ts"]); + }); + + it("does not mutate a copied file's live source path", () => { + expect( + operationPaths([{ path: "copy.ts", originalPath: "source.ts", status: "copied" }]), + ).toEqual(["copy.ts"]); + }); + + it("exposes snapshots only to the cwd that loaded them", () => { + const current = snapshot(); + const scoped = { cwd: "/repo/one", snapshot: current }; + + expect(snapshotForCwd(scoped, "/repo/one")).toBe(current); + expect(snapshotForCwd(scoped, "/repo/two")).toBeNull(); + }); + + it("stops treating a missing snapshot as pending after that cwd settles", () => { + expect(snapshotIsPendingForCwd(null, "/repo/one", null)).toBe(true); + expect(snapshotIsPendingForCwd(null, "/repo/one", "/repo/two")).toBe(true); + expect(snapshotIsPendingForCwd(null, "/repo/one", "/repo/one")).toBe(false); + expect(snapshotIsPendingForCwd(snapshot(), "/repo/one", null)).toBe(false); + expect(snapshotIsPendingForCwd(null, null, null)).toBe(false); + }); + + it("accepts a snapshot response only for the latest request and current cwd", () => { + expect(snapshotRequestIsCurrent(2, 2, "/repo/one", "/repo/one")).toBe(true); + expect(snapshotRequestIsCurrent(1, 2, "/repo/one", "/repo/one")).toBe(false); + expect(snapshotRequestIsCurrent(2, 2, "/repo/one", "/repo/two")).toBe(false); + }); + + it("clears only the shared banner for the resolved detail error", () => { + expect(clearResolvedDetailError("detail failed", "detail failed")).toBeNull(); + expect(clearResolvedDetailError("mutation failed", "detail failed")).toBe("mutation failed"); + expect(clearResolvedDetailError("detail failed", null)).toBe("detail failed"); + }); + + it("partitions mixed staged and unstaged files for complete discards", () => { + const files = panelChangeSets(snapshot(), "/repo")[0]?.files ?? []; + expect(discardPathGroups(files)).toEqual({ + staged: ["src/a.ts"], + unstaged: ["src/a.ts"], + }); + }); + + it("does not treat conflict-only files as unstaged discard targets", () => { + const conflictOnlyFile = { + path: "src/conflict.ts", + originalPath: null, + status: "conflicted" as const, + insertions: 0, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: false, + hasConflicts: true, + }; + expect(discardPathGroups([conflictOnlyFile])).toEqual({ staged: [], unstaged: [] }); + expect(discardableFiles([conflictOnlyFile])).toEqual([]); + }); + + it("opens only staged-only working-tree files from the staged side", () => { + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: true, + hasUnstagedChanges: false, + }), + ).toBe(true); + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: true, + hasUnstagedChanges: true, + }), + ).toBe(false); + expect( + workingTreeDiffIsStaged({ + hasStagedChanges: false, + hasUnstagedChanges: false, + }), + ).toBe(false); + }); + + it("keeps only files with discardable staged or unstaged changes", () => { + const files = panelChangeSets(snapshot(), "/repo")[0]?.files ?? []; + expect(discardableFiles(files)).toEqual(files); + }); + + it("selects new files and drops selection state for clean change sets", () => { + const [changeSet] = panelChangeSets(snapshot(), "/repo"); + if (!changeSet) throw new Error("Expected a current working-tree fixture"); + const next = reconcileSelectedPaths({ + changeSets: [ + { + ...changeSet, + files: [ + ...changeSet.files, + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 1, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: true, + hasConflicts: false, + }, + ], + }, + ], + previousKnownPaths: new Map([ + ["/repo", new Set(["src/a.ts"])], + ["/clean", new Set(["src/done.ts"])], + ]), + selectedByCwd: new Map([ + ["/repo", new Set(["src/a.ts"])], + ["/clean", new Set(["src/done.ts"])], + ]), + }); + + expect([...next.entries()].map(([cwd, paths]) => [cwd, [...paths]])).toEqual([ + ["/repo", ["src/a.ts", "src/new.ts"]], + ]); + }); + + it("applies cwd-scoped untracked file enrichment", () => { + const next: VcsPanelSnapshotResult = { + ...snapshot(), + changeGroups: [ + { + kind: "unstaged", + files: [ + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }, + ], + }, + ], + }; + + expect(workingTreeEnrichmentRequests(next, "/repo")).toEqual([ + { cwd: "/repo", paths: ["src/new.ts"] }, + ]); + const enriched = applyWorkingTreeEnrichments( + next, + "/repo", + new Map([ + [ + "/repo", + { + files: [ + { + path: "src/new.ts", + originalPath: null, + status: "untracked", + insertions: 12, + deletions: 0, + }, + ], + hiddenPaths: [], + }, + ], + ]), + ); + expect(panelChangeSets(enriched, "/repo")[0]?.files[0]?.insertions).toBe(12); + }); + + it("only offers merge sync when the target cwd owns the branch", () => { + const [current, localOnly] = snapshot().localBranches; + if (!current || !localOnly) throw new Error("Expected current and local-only branch fixtures"); + expect(branchOwnsOperationCwd(current)).toBe(true); + expect(branchOwnsOperationCwd(localOnly)).toBe(false); + expect(branchOwnsOperationCwd({ ...localOnly, worktreePath: "/repo-worktree" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/version-control/versionControlModel.ts b/apps/mobile/src/features/version-control/versionControlModel.ts new file mode 100644 index 00000000000..2542e1739ef --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlModel.ts @@ -0,0 +1,301 @@ +import type { + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsPanelWorkingTreeFileEnrichmentResult, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import { + mergePanelChangeGroups, + panelBranchHasUpstream, + panelBranchSyncCounts, + type PanelChangedFile, +} from "@t3tools/shared/sourceControl"; + +import { relativeTime } from "../../lib/time"; + +export interface VersionControlChangeSet { + readonly id: string; + readonly branchName: string; + readonly cwd: string; + readonly current: boolean; + readonly lastActivityAt?: string | null; + readonly files: readonly PanelChangedFile[]; +} + +function siblingChangeSet(changeSet: VcsPanelWorktreeChangeSet): VersionControlChangeSet { + return { + id: `worktree:${changeSet.worktreePath}`, + branchName: changeSet.branchName, + cwd: changeSet.worktreePath, + current: changeSet.current, + lastActivityAt: changeSet.lastActivityAt, + files: mergePanelChangeGroups(changeSet.changeGroups), + }; +} + +export function panelChangeSets( + snapshot: VcsPanelSnapshotResult, + cwd: string, +): VersionControlChangeSet[] { + const currentBranch = snapshot.status.refName ?? "Detached HEAD"; + const current: VersionControlChangeSet = { + id: `worktree:${cwd}`, + branchName: currentBranch, + cwd, + current: true, + files: mergePanelChangeGroups(snapshot.changeGroups), + }; + + return [current, ...snapshot.worktreeChangeSets.map(siblingChangeSet)].filter( + (changeSet, index, all) => + changeSet.files.length > 0 && + all.findIndex((candidate) => candidate.cwd === changeSet.cwd) === index, + ); +} + +export function reconcileSelectedPaths(input: { + readonly changeSets: readonly VersionControlChangeSet[]; + readonly previousKnownPaths: ReadonlyMap>; + readonly selectedByCwd: ReadonlyMap>; +}): ReadonlyMap> { + const next = new Map>(); + for (const changeSet of input.changeSets) { + const known = input.previousKnownPaths.get(changeSet.cwd) ?? new Set(); + const visible = new Set(changeSet.files.map((file) => file.path)); + const selected = new Set(input.selectedByCwd.get(changeSet.cwd) ?? []); + for (const path of visible) { + if (!known.has(path)) selected.add(path); + } + for (const path of selected) { + if (!visible.has(path)) selected.delete(path); + } + next.set(changeSet.cwd, selected); + } + return next; +} + +export function actionableLocalBranches(snapshot: VcsPanelSnapshotResult): VcsRef[] { + return snapshot.localBranches.filter((branch) => { + const { aheadCount, behindCount } = panelBranchSyncCounts(branch, snapshot); + return !panelBranchHasUpstream(branch, snapshot) || aheadCount > 0 || behindCount > 0; + }); +} + +export function stashIdentityKey(stash: Pick): string { + return stash.sha ? `sha:${stash.sha}` : `ref:${stash.refName}`; +} + +export function relativeLabel(value: string | null | undefined): string | null { + if (!value || Number.isNaN(Date.parse(value))) return null; + return relativeTime(value); +} + +export function localBranchForRemoteBranch( + snapshot: VcsPanelSnapshotResult, + remote: Pick, + 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 + ); +} + +export function visibleRemoteBranches( + remote: Pick, + expanded: boolean, +): VcsPanelRemote["branches"] { + return expanded ? remote.branches : []; +} + +export function operationPaths( + files: readonly Pick[], +) { + return [ + ...new Set( + files.flatMap((file) => + file.status === "renamed" && file.originalPath + ? [file.path, file.originalPath] + : [file.path], + ), + ), + ]; +} + +export interface CwdScopedSnapshot { + readonly cwd: string; + readonly snapshot: VcsPanelSnapshotResult; +} + +export function snapshotForCwd( + scopedSnapshot: CwdScopedSnapshot | null, + cwd: string | null | undefined, +): VcsPanelSnapshotResult | null { + return scopedSnapshot !== null && scopedSnapshot.cwd === cwd ? scopedSnapshot.snapshot : null; +} + +export function snapshotIsPendingForCwd( + snapshot: VcsPanelSnapshotResult | null, + cwd: string | null | undefined, + settledSnapshotCwd: string | null, +): boolean { + return cwd != null && snapshot === null && settledSnapshotCwd !== cwd; +} + +export function snapshotRequestIsCurrent( + requestId: number, + latestRequestId: number, + requestedCwd: string, + currentCwd: string | null | undefined, +): boolean { + return requestId === latestRequestId && requestedCwd === currentCwd; +} + +export function clearResolvedDetailError( + currentError: string | null, + resolvedDetailError: string | null, +): string | null { + return resolvedDetailError !== null && currentError === resolvedDetailError ? null : currentError; +} + +export function discardPathGroups(files: readonly PanelChangedFile[]): { + readonly staged: readonly string[]; + readonly unstaged: readonly string[]; +} { + return { + staged: operationPaths(files.filter((file) => file.hasStagedChanges)), + unstaged: operationPaths(files.filter((file) => file.hasUnstagedChanges)), + }; +} + +export function discardableFiles(files: readonly PanelChangedFile[]): readonly PanelChangedFile[] { + return files.filter((file) => file.hasStagedChanges || file.hasUnstagedChanges); +} + +export function workingTreeDiffIsStaged( + file: Pick, +): boolean { + return file.hasStagedChanges && !file.hasUnstagedChanges; +} + +export function workingTreeEnrichmentRequests( + snapshot: VcsPanelSnapshotResult, + cwd: string, +): ReadonlyArray<{ readonly cwd: string; readonly paths: readonly string[] }> { + return panelChangeSets(snapshot, cwd).flatMap((changeSet) => { + const paths = changeSet.files + .filter( + (file) => + file.hasUnstagedChanges && (file.status === "untracked" || file.status === "deleted"), + ) + .map((file) => file.path); + return paths.length > 0 ? [{ cwd: changeSet.cwd, paths }] : []; + }); +} + +function applyWorkingTreeEnrichment( + groups: readonly VcsPanelChangeGroup[], + enrichment: VcsPanelWorkingTreeFileEnrichmentResult | undefined, +): VcsPanelChangeGroup[] { + if (!enrichment) return groups.map((group) => ({ ...group, files: [...group.files] })); + + const enrichedByPath = new Map(enrichment.files.map((file) => [file.path, file])); + const hiddenPaths = new Set(enrichment.hiddenPaths); + 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 enriched = enrichedByPath.get(file.path) ?? file; + seenPaths.add(enriched.path); + return [enriched]; + }); + for (const file of enrichment.files) { + if (!seenPaths.has(file.path) && !hiddenPaths.has(file.path)) files.push(file); + } + return { + ...group, + files: files.sort((left, right) => left.path.localeCompare(right.path)), + }; + }); +} + +export function applyWorkingTreeEnrichments( + snapshot: VcsPanelSnapshotResult, + cwd: string, + enrichments: ReadonlyMap, +): VcsPanelSnapshotResult { + return { + ...snapshot, + changeGroups: applyWorkingTreeEnrichment(snapshot.changeGroups, enrichments.get(cwd)), + worktreeChangeSets: snapshot.worktreeChangeSets.map((changeSet) => ({ + ...changeSet, + changeGroups: applyWorkingTreeEnrichment( + changeSet.changeGroups, + enrichments.get(changeSet.worktreePath), + ), + })), + }; +} + +export function branchOwnsOperationCwd(branch: VcsRef): boolean { + return branch.current || branch.worktreePath !== null; +} + +export function selectedFileStats( + files: readonly Pick[], +) { + return files.reduce( + (total, file) => ({ + insertions: total.insertions + file.insertions, + deletions: total.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export 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 "!"; + case "modified": + return "M"; + } +} + +export function branchSyncLabel(input: { + readonly state: "fetch" | "pull" | "push" | "publish" | "diverged"; + readonly busy: boolean; +}): string { + if (input.busy) return "Working…"; + switch (input.state) { + case "fetch": + return "Fetch"; + case "pull": + return "Pull"; + case "push": + return "Push"; + case "publish": + return "Publish"; + case "diverged": + return "Sync"; + } +} diff --git a/apps/mobile/src/features/version-control/versionControlRequest.test.ts b/apps/mobile/src/features/version-control/versionControlRequest.test.ts new file mode 100644 index 00000000000..aa0cf3fc94d --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlRequest.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + retryInterruptedVersionControlRequest, + VersionControlCommandInterrupted, +} from "./versionControlRequest"; + +describe("native Version Control requests", () => { + it("retries an interrupted request", async () => { + const request = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new VersionControlCommandInterrupted()) + .mockResolvedValueOnce("loaded"); + + await expect(retryInterruptedVersionControlRequest(request)).resolves.toBe("loaded"); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("does not retry other failures", async () => { + const error = new Error("failed"); + const request = vi.fn<() => Promise>().mockRejectedValue(error); + + await expect(retryInterruptedVersionControlRequest(request)).rejects.toBe(error); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("bounds repeated interruption retries", async () => { + const request = vi + .fn<() => Promise>() + .mockRejectedValue(new VersionControlCommandInterrupted()); + + await expect(retryInterruptedVersionControlRequest(request)).rejects.toBeInstanceOf( + VersionControlCommandInterrupted, + ); + expect(request).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/features/version-control/versionControlRequest.ts b/apps/mobile/src/features/version-control/versionControlRequest.ts new file mode 100644 index 00000000000..0fca57f60c6 --- /dev/null +++ b/apps/mobile/src/features/version-control/versionControlRequest.ts @@ -0,0 +1,23 @@ +export class VersionControlCommandInterrupted extends Error { + constructor() { + super("The Version Control command was interrupted."); + this.name = "VersionControlCommandInterrupted"; + } +} + +export async function retryInterruptedVersionControlRequest( + request: () => Promise, + maxRetries = 1, +): Promise { + let retries = 0; + while (true) { + try { + return await request(); + } catch (cause) { + if (!(cause instanceof VersionControlCommandInterrupted) || retries >= maxRetries) { + throw cause; + } + retries += 1; + } + } +} diff --git a/apps/mobile/src/state/use-atom-query-runner.ts b/apps/mobile/src/state/use-atom-query-runner.ts index 22f971e09a5..66f10cd02fa 100644 --- a/apps/mobile/src/state/use-atom-query-runner.ts +++ b/apps/mobile/src/state/use-atom-query-runner.ts @@ -15,6 +15,7 @@ export function useAtomQueryRunner( const explicitLabel = typeof options === "string" ? options : options?.label; const reportFailure = typeof options === "string" ? true : (options?.reportFailure ?? true); const reportDefect = typeof options === "string" ? true : (options?.reportDefect ?? true); + const forceRefresh = typeof options === "string" ? false : (options?.forceRefresh ?? false); return useCallback( (target: T) => { @@ -23,8 +24,9 @@ export function useAtomQueryRunner( label: explicitLabel ?? atom.label?.[0] ?? "atom query", reportFailure, reportDefect, + forceRefresh, }); }, - [explicitLabel, family, registry, reportDefect, reportFailure], + [explicitLabel, family, forceRefresh, registry, reportDefect, reportFailure], ); } diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da710..78452ff0879 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -131,9 +131,15 @@ export function useSelectedThreadGitActions() { readonly project: EnvironmentProject; readonly cwd: string; }) => Promise>, - options?: { readonly managedExternally?: boolean }, + options?: { + readonly managedExternally?: boolean; + readonly throwOnFailure?: boolean; + }, ): Promise => { if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { + if (options?.throwOnFailure === true) { + throw new Error("No repository is available for this Git action."); + } return null; } @@ -153,10 +159,16 @@ export function useSelectedThreadGitActions() { ? await run() : await vcsActionManager.track(appAtomRegistry, target, { operation, label }, run); if (AsyncResult.isFailure(result)) { + if (Cause.hasInterruptsOnly(result.cause)) { + return null; + } const error = Cause.squash(result.cause); const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); + if (options?.throwOnFailure === true) { + throw error; + } return null; } return result.value; @@ -194,8 +206,8 @@ export function useSelectedThreadGitActions() { ); const onCheckoutSelectedThreadBranch = useCallback( - async (branch: string) => { - await runSelectedThreadGitMutation( + async (branch: string, options?: { readonly throwOnFailure?: boolean }) => { + return await runSelectedThreadGitMutation( "switch_ref", "Switching branch", async ({ thread, cwd }) => { @@ -216,6 +228,7 @@ export function useSelectedThreadGitActions() { }); return AsyncResult.isFailure(syncResult) ? AsyncResult.failure(syncResult.cause) : result; }, + options, ); }, [ diff --git a/apps/server/src/diagnostics/ErrorCause.ts b/apps/server/src/diagnostics/ErrorCause.ts new file mode 100644 index 00000000000..42a9543afc1 --- /dev/null +++ b/apps/server/src/diagnostics/ErrorCause.ts @@ -0,0 +1,82 @@ +const MAX_DIAGNOSTIC_TEXT_LENGTH = 2_000; + +export interface SanitizedErrorCause { + readonly tag?: string; + readonly name?: string; + readonly message?: string; + readonly detail?: string; + readonly code?: string; + readonly exitCode?: number; + readonly timeoutMs?: number; + readonly truncated?: boolean; +} + +type MutableSanitizedErrorCause = { + -readonly [Key in keyof SanitizedErrorCause]?: SanitizedErrorCause[Key]; +}; + +function truncateDiagnosticText(value: string): { + readonly text: string; + readonly truncated: boolean; +} { + if (value.length <= MAX_DIAGNOSTIC_TEXT_LENGTH) { + return { text: value, truncated: false }; + } + return { + text: `${value.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH)}\n\n[truncated]`, + truncated: true, + }; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function addTextField( + output: MutableSanitizedErrorCause, + key: "tag" | "name" | "message" | "detail" | "code", + value: string | undefined, +) { + if (!value) return; + const truncated = truncateDiagnosticText(value); + output[key] = truncated.text; + if (truncated.truncated) output.truncated = true; +} + +export function sanitizeErrorCause(cause: unknown): SanitizedErrorCause { + if (typeof cause === "string") { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "message", cause); + return output; + } + + if (typeof cause === "object" && cause !== null) { + const record = cause as Record; + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "tag", stringField(record, "_tag")); + addTextField(output, "name", cause instanceof Error ? cause.name : stringField(record, "name")); + addTextField( + output, + "message", + cause instanceof Error ? cause.message : stringField(record, "message"), + ); + addTextField(output, "detail", stringField(record, "detail")); + addTextField(output, "code", stringField(record, "code")); + + const exitCode = numberField(record, "exitCode"); + if (exitCode !== undefined) output.exitCode = exitCode; + + const timeoutMs = numberField(record, "timeoutMs"); + if (timeoutMs !== undefined) output.timeoutMs = timeoutMs; + + return Object.keys(output).length > 0 ? output : { tag: "Object" }; + } + + return { message: "Unknown error" }; +} diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 695c7b76f64..4dfae7c7f27 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -559,6 +559,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + getCommitAvatarUrl: () => Effect.succeed(null), createRepository: (input) => Effect.fail( new GitHubCli.GitHubCliCommandError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 1cd4b388552..a4eb4cebeb5 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -188,6 +188,51 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("lists pull requests against an explicit Azure organization", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]"))); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.listPullRequests({ + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "pr", + "list", + "--detect", + "true", + "--organization", + "https://dev.azure.com/acme", + "--repository", + "repo", + "--project", + "project", + "--source-branch", + "feature/provider", + "--status", + "active", + "--top", + "20", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("reads repository clone URLs", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -221,6 +266,62 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("requests commit avatars from the explicit Azure organization", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + author: { + imageUrl: "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getCommitAvatarUrl({ + cwd: "/repo", + organization: "https://dev.azure.com/acme", + project: "project", + repository: "repo", + sha: "abc123", + }); + + assert.strictEqual( + result, + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "devops", + "invoke", + "--organization", + "https://dev.azure.com/acme", + "--area", + "git", + "--resource", + "commits", + "--route-parameters", + "project=project", + "repositoryId=repo", + "commitId=abc123", + "--api-version", + "7.1", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories through Azure Repos", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index 609efe4df4c..c397bab00b0 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -157,6 +157,7 @@ const AzureDevOpsRepositoryDecodeOperation = Schema.Literals([ "getRepositoryCloneUrls", "getDefaultBranch", "createRepository", + "getCommitAvatarUrl", ]); export class AzureDevOpsRepositoryDecodeError extends Schema.TaggedErrorClass()( @@ -207,6 +208,9 @@ export class AzureDevOpsCli extends Context.Service< readonly cwd: string; readonly headSelector: string; readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly organization?: string; + readonly repository?: string; + readonly project?: string; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, AzureDevOpsCliError>; @@ -221,6 +225,14 @@ export class AzureDevOpsCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly organization: string; + readonly repository: string; + readonly sha: string; + readonly project?: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -281,6 +293,14 @@ const RawAzureDevOpsRepositorySchema = Schema.Struct({ defaultBranch: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawAzureDevOpsCommitSchema = Schema.Struct({ + author: Schema.optional( + Schema.Struct({ + imageUrl: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), +}); + function normalizeDefaultBranch(value: string | null | undefined): string | null { const trimmed = value?.trim().replace(/^refs\/heads\//, "") ?? ""; return trimmed.length > 0 ? trimmed : null; @@ -377,6 +397,9 @@ export const make = Effect.gen(function* () { "list", "--detect", "true", + ...(input.organization ? ["--organization", input.organization] : []), + ...(input.repository ? ["--repository", input.repository] : []), + ...(input.project ? ["--project", input.project] : []), "--source-branch", SourceControlProvider.sourceBranch(input), "--status", @@ -458,6 +481,35 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => + executeJson({ + cwd: input.cwd, + args: [ + "devops", + "invoke", + "--organization", + input.organization, + "--area", + "git", + "--resource", + "commits", + "--route-parameters", + ...(input.project ? [`project=${input.project}`] : []), + `repositoryId=${input.repository}`, + `commitId=${input.sha}`, + "--api-version", + "7.1", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeAzureDevOpsJson(raw, RawAzureDevOpsCommitSchema, "getCommitAvatarUrl", input.cwd), + ), + Effect.map((commit) => { + const avatarUrl = commit.author?.imageUrl?.trim(); + return avatarUrl && avatarUrl.length > 0 ? avatarUrl : null; + }), + ), createRepository: (input) => { const repository = parseRepositorySpecifier(input.repository); // Azure Repos access is governed by project/organization permissions. diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e7991..01cff3fa71f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -46,7 +46,163 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); -it.effect("adds change-request context while retaining Azure CLI causes", () => +it.effect("lists Azure DevOps PRs against the requested remote repository context", () => + Effect.gen(function* () { + let listInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + }, + headSelector: "feature/provider", + state: "open", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + limit: 10, + }); + }), +); + +it.effect("preserves Azure DevOps fallback project context when _git is absent", () => + Effect.gen(function* () { + let listInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + listPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "legacy", + remoteUrl: "https://dev.azure.com/acme/project/repo", + }, + headSelector: "feature/provider", + state: "open", + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + state: "open", + }); + }), +); + +it.effect("uses Azure DevOps commit author image URLs from the remote repository context", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed( + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "https://dev.azure.com/acme/project/_git/repo", + }, + sha: "abc123", + }); + + assert.strictEqual( + avatarUrl, + "https://dev.azure.com/acme/_apis/GraphProfile/MemberAvatars/aad.123", + ); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + sha: "abc123", + }); + }), +); + +it.effect("routes Azure SSH avatar lookups through the remote organization", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed(null); + }, + }); + + yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: "https://dev.azure.com", + }, + remoteName: "upstream", + remoteUrl: "git@ssh.dev.azure.com:v3/acme/project/repo", + }, + sha: "abc123", + }); + + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + organization: "https://dev.azure.com/acme", + repository: "repo", + project: "project", + sha: "abc123", + }); + }), +); + +it.effect("adds change-request context while bounding Azure CLI causes", () => Effect.gen(function* () { const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ operation: "execute", @@ -81,7 +237,14 @@ it.effect("adds change-request context while retaining Azure CLI causes", () => detail: "Azure DevOps CLI command failed.", }, ); - assert.strictEqual(error.cause, cause); + assert.deepStrictEqual(error.cause, { + _tag: "AzureDevOpsCommandFailedError", + name: "AzureDevOpsCommandFailedError", + operation: "execute", + command: "az", + detail: "Azure DevOps CLI command failed.", + message: "Azure DevOps CLI failed in execute: Azure DevOps CLI command failed.", + }); assert.equal(error.message.includes("raw upstream detail"), false); }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac982927..5b4b3453bda 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; +import type { ChangeRequest } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; @@ -72,6 +72,63 @@ function toChangeRequest(summary: { }; } +function azureRepositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): + | { + readonly organization: string; + readonly repository: string; + readonly project?: string; + } + | undefined { + if (!context) return undefined; + const path = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!path) return undefined; + const parts = path + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const organization = (() => { + try { + const url = new URL(context.remoteUrl); + const hostname = url.hostname.toLowerCase(); + if (hostname === "dev.azure.com") { + const name = parts[0]; + return name ? `${url.origin}/${encodeURIComponent(name)}` : undefined; + } + if (hostname.endsWith(".visualstudio.com")) { + return url.origin; + } + } catch { + if (parts[0]?.toLowerCase() === "v3" && parts[1]) { + return `https://dev.azure.com/${encodeURIComponent(parts[1])}`; + } + } + return undefined; + })(); + if (!organization) return undefined; + const gitIndex = parts.findIndex((part) => part.toLowerCase() === "_git"); + const gitProject = parts[gitIndex - 1]; + const gitRepository = parts[gitIndex + 1]; + if (gitIndex >= 1 && gitProject && gitRepository) { + return { organization, project: gitProject, repository: gitRepository }; + } + const sshProject = parts[2]; + const sshRepository = parts[3]; + if (parts[0]?.toLowerCase() === "v3" && sshProject && sshRepository) { + return { organization, project: sshProject, repository: sshRepository }; + } + const fallbackProject = parts.at(-2); + const repository = parts.at(-1); + return repository + ? { + organization, + repository, + ...(fallbackProject ? { project: fallbackProject } : {}), + } + : undefined; +} + export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; @@ -79,48 +136,46 @@ export const make = Effect.gen(function* () { kind: "azure-devops", listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); + const repository = azureRepositoryFromContext(input.context); return azure .listPullRequests({ cwd: input.cwd, headSelector: input.headSelector, ...(source !== undefined ? { source } : {}), + ...(repository !== undefined + ? { + organization: repository.organization, + repository: repository.repository, + ...(repository.project !== undefined ? { project: repository.project } : {}), + } + : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "listChangeRequests", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); }, getChangeRequest: (input) => azure.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), createChangeRequest: (input) => { @@ -136,68 +191,73 @@ export const make = Effect.gen(function* () { bodyFile: input.bodyFile, }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "createChangeRequest", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); }, getRepositoryCloneUrls: (input) => azure.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), - createRepository: (input) => - azure.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ + getCommitAvatarUrl: (input) => { + const repository = azureRepositoryFromContext(input.context); + if (!repository) { + return Effect.succeed(null); + } + return azure + .getCommitAvatarUrl({ + cwd: input.cwd, + ...repository, + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ provider: "azure-devops", - operation: "createRepository", - command: error.command, + operation: "getCommitAvatarUrl", cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, + reference: input.sha, + error, }), + ), + ); + }, + createRepository: (input) => + azure.createRepository(input).pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "createRepository", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), getDefaultBranch: (input) => azure.getDefaultBranch({ cwd: input.cwd }).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "getDefaultBranch", + cwd: input.cwd, + error, + }), ), ), checkoutChangeRequest: (input) => @@ -208,19 +268,14 @@ export const make = Effect.gen(function* () { ...(input.context !== undefined ? { remoteName: input.context.remoteName } : {}), }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "azure-devops", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "azure-devops", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), }); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a4..b2e8cc88311 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -41,6 +41,7 @@ const BitbucketApiOperation = Schema.Literals([ "resolveRepository", "getRepository", "getBranchingModel", + "getCommitAvatarUrl", "getPullRequest", "listPullRequests", "createRepository", @@ -231,6 +232,28 @@ const RawBitbucketBranchingModelSchema = Schema.Struct({ ), }); +const RawBitbucketCommitSchema = Schema.Struct({ + author: Schema.optional( + Schema.Struct({ + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ + links: Schema.optional( + Schema.Struct({ + avatar: Schema.optional( + Schema.Struct({ + href: TrimmedNonEmptyString, + }), + ), + }), + ), + }), + ), + ), + }), + ), +}); + const BitbucketUserSchema = Schema.Struct({ username: Schema.optional(TrimmedNonEmptyString), display_name: Schema.optional(TrimmedNonEmptyString), @@ -264,6 +287,11 @@ export class BitbucketApi extends Context.Service< readonly context?: SourceControlProvider.SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; + readonly sha: string; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -734,6 +762,21 @@ export const make = Effect.gen(function* () { getRawPullRequest(input).pipe(Effect.map(normalizeBitbucketPullRequestRecord)), getRepositoryCloneUrls: (input) => getRepository(input).pipe(Effect.map(normalizeRepositoryCloneUrls)), + getCommitAvatarUrl: (input) => + resolveRepository(input).pipe( + Effect.flatMap((repository) => + executeJson( + "getCommitAvatarUrl", + HttpClientRequest.get( + apiUrl( + `/repositories/${encodeURIComponent(repository.workspace)}/${encodeURIComponent(repository.repoSlug)}/commit/${encodeURIComponent(input.sha)}`, + ), + ), + RawBitbucketCommitSchema, + ), + ), + Effect.map((commit) => commit.author?.user?.links?.avatar?.href ?? null), + ), createRepository: (input) => requireRepositoryLocator(input.repository).pipe( Effect.flatMap((repository) => diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts index eeb4c8fbdd2..bd82bc7810a 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.test.ts @@ -166,3 +166,46 @@ it.effect("uses Bitbucket API repository detection for default branch lookup", ( assert.strictEqual(cwdInput, "/repo"); }), ); + +it.effect("uses Bitbucket account avatar links through the provider API", () => + Effect.gen(function* () { + let avatarInput: + | Parameters[0] + | null = null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed("https://bitbucket.org/account/user/example/avatar/32/"); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "https://bitbucket.org/workspace/repo.git", + }, + sha: "abc123", + }); + + assert.strictEqual(avatarUrl, "https://bitbucket.org/account/user/example/avatar/32/"); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + context: { + provider: { + kind: "bitbucket", + name: "Bitbucket", + baseUrl: "https://bitbucket.org", + }, + remoteName: "origin", + remoteUrl: "https://bitbucket.org/workspace/repo.git", + }, + sha: "abc123", + }); + }), +); diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 974fbb94a39..973bf6fde2e 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -125,6 +125,25 @@ export const make = Effect.gen(function* () { }), ), ), + getCommitAvatarUrl: (input) => + bitbucket + .getCommitAvatarUrl({ + cwd: input.cwd, + ...(input.context ? { context: input.context } : {}), + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "bitbucket", + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: input.sha, + detail: "Failed to get commit author avatar.", + error, + }), + ), + ), createRepository: (input) => bitbucket.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60..818779c6266 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -157,6 +157,35 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("fetches commit author avatar URLs from the GitHub commit API", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed(processOutput("https://avatars.githubusercontent.com/u/101?v=4\n")), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getCommitAvatarUrl({ + cwd: "/repo", + repository: "pingdotgg/t3code", + sha: "a".repeat(40), + }); + + assert.equal(result, "https://avatars.githubusercontent.com/u/101?v=4"); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "api", + "repos/pingdotgg/t3code/commits/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "--jq", + ".author.avatar_url // empty", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("skips invalid entries when parsing pr lists", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..1a7f97bbbd7 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -208,6 +208,7 @@ export class GitHubCli extends Context.Service< readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; + readonly repository?: string; readonly limit?: number; }) => Effect.Effect, GitHubCliError>; @@ -221,6 +222,13 @@ export class GitHubCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly repository: string; + readonly sha: string; + readonly hostname?: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -325,6 +333,7 @@ export const make = Effect.gen(function* () { args: [ "pr", "list", + ...(input.repository ? ["--repo", input.repository] : []), "--head", input.headSelector, "--state", @@ -410,6 +419,22 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => + execute({ + cwd: input.cwd, + args: [ + "api", + ...(input.hostname !== undefined ? ["--hostname", input.hostname] : []), + `repos/${input.repository}/commits/${input.sha}`, + "--jq", + ".author.avatar_url // empty", + ], + }).pipe( + Effect.map((result) => { + const trimmed = result.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ), createRepository: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..9df37da04f6 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -68,7 +68,43 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +it.effect("lists GitHub PRs against the requested remote repository context", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + listOpenPullRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "github", + name: "GitHub Enterprise", + baseUrl: "https://github.example.test", + }, + remoteName: "upstream", + remoteUrl: "git@github.example.test:acme/repo.git", + }, + headSelector: "feature/provider", + state: "open", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: "github.example.test/acme/repo", + limit: 10, + }); + }), +); + +it.effect("adds safe request context while bounding GitHub CLI causes", () => Effect.gen(function* () { const cause = new GitHubCli.GitHubPullRequestNotFoundError({ command: "gh", @@ -104,7 +140,14 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => detail: "Pull request not found. Check the PR number or URL and try again.", }, ); - assert.strictEqual(error.cause, cause); + assert.deepStrictEqual(error.cause, { + _tag: "GitHubPullRequestNotFoundError", + name: "GitHubPullRequestNotFoundError", + command: "gh", + detail: "Pull request not found. Check the PR number or URL and try again.", + message: + "GitHub CLI failed in execute: Pull request not found. Check the PR number or URL and try again.", + }); assert.equal(error.message.includes("raw upstream detail"), false); }), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..0e3d6f1f277 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -2,11 +2,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import { - SourceControlProviderError, - type ChangeRequest, - type ChangeRequestState, -} from "@t3tools/contracts"; +import type { ChangeRequest, ChangeRequestState } from "@t3tools/contracts"; import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; @@ -42,6 +38,34 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +function repositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): string | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const host = new URL(context.provider.baseUrl).host; + return host && host !== "github.com" ? `${host}/${repository}` : repository; + } catch { + return repository; + } +} + +function repositoryContextFromProviderContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): { readonly repository: string; readonly hostname?: string } | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const hostname = new URL(context.provider.baseUrl).host; + return hostname && hostname !== "github.com" ? { repository, hostname } : { repository }; + } catch { + return { repository }; + } +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -99,28 +123,25 @@ export const make = Effect.gen(function* () { const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { + const repository = repositoryFromContext(input.context); if (input.state === "open") { return github .listOpenPullRequests({ cwd: input.cwd, headSelector: input.headSelector, + ...(repository ? { repository } : {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); } @@ -132,6 +153,7 @@ export const make = Effect.gen(function* () { args: [ "pr", "list", + ...(repository ? ["--repo", repository] : []), "--head", input.headSelector, "--state", @@ -167,19 +189,14 @@ export const make = Effect.gen(function* () { ), ); }), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); }; @@ -190,19 +207,14 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => github.getPullRequest(input).pipe( Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), createChangeRequest: (input) => @@ -215,84 +227,84 @@ export const make = Effect.gen(function* () { bodyFile: input.bodyFile, }) .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "createChangeRequest", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ), getRepositoryCloneUrls: (input) => github.getRepositoryCloneUrls(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - command: error.command, - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getRepositoryCloneUrls", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), - createRepository: (input) => - github.createRepository(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ + getCommitAvatarUrl: (input) => { + const repository = repositoryContextFromProviderContext(input.context); + if (!repository) { + return Effect.succeed(null); + } + return github + .getCommitAvatarUrl({ + cwd: input.cwd, + ...repository, + sha: input.sha, + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ provider: "github", - operation: "createRepository", - command: error.command, + operation: "getCommitAvatarUrl", cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.detail, - cause: error, + reference: input.sha, + error, }), + ), + ); + }, + createRepository: (input) => + github.createRepository(input).pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "createRepository", + cwd: input.cwd, + repository: input.repository, + error, + }), ), ), getDefaultBranch: (input) => github.getDefaultBranch(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - command: error.command, - cwd: input.cwd, - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "getDefaultBranch", + cwd: input.cwd, + error, + }), ), ), checkoutChangeRequest: (input) => github.checkoutPullRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + cwd: input.cwd, + reference: input.reference, + error, + }), ), ), }); diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 87621e5c8bc..6e66f5b2edd 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -184,6 +184,149 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect("reads GitLab-hosted avatar URLs through the official Avatar API", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + avatar_url: "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getCommitAvatarUrl({ + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }); + + assert.strictEqual( + result, + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + expect(mockedRun).toHaveBeenCalledWith( + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", "--hostname", "gitlab.example.test", "avatar?email=author%40example.test"], + }), + ); + }), + ); + + it.effect("passes through external GitLab Avatar API fallback URLs", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + avatar_url: "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61", + }), + ), + ), + ); + + const result = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab.getCommitAvatarUrl({ + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }); + + assert.strictEqual( + result, + "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61", + ); + }), + ); + + it.effect("preserves structured process failures when normalizing CLI errors", () => + Effect.gen(function* () { + const cause = { + _tag: "VcsProcessSpawnError", + detail: "Command not found: glab", + }; + mockedRun.mockReturnValueOnce(Effect.fail(cause) as never); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "list"], + }) + .pipe(Effect.flip); + }); + + expect(error.detail).toBe("GitLab CLI (`glab`) is required but not available on PATH."); + expect(error._tag).toBe("GitLabCliUnavailableError"); + expect(error.cause).toEqual({ + _tag: "VcsProcessSpawnError", + detail: "Command not found: glab", + }); + }), + ); + + it.effect("classifies GitLab CLI errors from detail text without rendering tags", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "gitlab.execute", + command: "glab mr view 42", + cwd: "/repo", + exitCode: 1, + detail: "fatal: could not read from remote repository", + }); + mockedRun.mockReturnValueOnce(Effect.fail(cause) as never); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "view", "42"], + }) + .pipe(Effect.flip); + }); + + expect(error.detail).toBe("GitLab CLI command failed."); + expect(error.detail).not.toContain("VcsProcessExitError"); + expect(error.cause).toBe(cause); + }), + ); + + it.effect("does not classify ambiguous token text as authentication failures", () => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.fail({ + _tag: "VcsProcessExitError", + detail: "GraphQL parser found token near merge request query", + }) as never, + ); + + const error = yield* Effect.gen(function* () { + const glab = yield* GitLabCli.GitLabCli; + return yield* glab + .execute({ + cwd: "/repo", + args: ["mr", "list"], + }) + .pipe(Effect.flip); + }); + + expect(error._tag).toBe("GitLabCliCommandError"); + expect(error.detail).toBe("GitLab CLI command failed."); + }), + ); + it.effect("creates merge requests through the GitLab API without placing the body in argv", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("{}"))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index a2926afd0ef..c49217ca6e8 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import type * as DateTime from "effect/DateTime"; import { + TrimmedString, TrimmedNonEmptyString, type SourceControlRepositoryVisibility, type VcsError, @@ -193,6 +194,22 @@ export class GitLabRepositoryDecodeError extends Schema.TaggedErrorClass()( + "GitLabAvatarDecodeError", + { + ...gitLabCliDecodeErrorContext, + operation: Schema.Literal("getCommitAvatarUrl"), + }, +) { + get detail(): string { + return "GitLab CLI returned invalid avatar JSON."; + } + + override get message(): string { + return `GitLab CLI failed in ${this.operation}: ${this.detail}`; + } +} + export class GitLabNamespaceDecodeError extends Schema.TaggedErrorClass()( "GitLabNamespaceDecodeError", { @@ -218,6 +235,7 @@ export const GitLabCliError = Schema.Union([ GitLabMergeRequestListDecodeError, GitLabMergeRequestDecodeError, GitLabRepositoryDecodeError, + GitLabAvatarDecodeError, GitLabNamespaceDecodeError, ]); export type GitLabCliError = typeof GitLabCliError.Type; @@ -255,6 +273,7 @@ export class GitLabCli extends Context.Service< readonly cwd: string; readonly headSelector: string; readonly source?: SourceControlProvider.SourceControlRefSelector; + readonly repository?: string; readonly state: "open" | "closed" | "merged" | "all"; readonly limit?: number; }) => Effect.Effect, GitLabCliError>; @@ -269,6 +288,12 @@ export class GitLabCli extends Context.Service< readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly email: string; + readonly providerBaseUrl: string; + }) => Effect.Effect; + readonly createRepository: (input: { readonly cwd: string; readonly repository: string; @@ -308,6 +333,10 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +const RawGitLabAvatarSchema = Schema.Struct({ + avatar_url: Schema.optional(Schema.NullOr(TrimmedString)), +}); + const RawGitLabNamespaceSchema = Schema.Struct({ id: Schema.Number, }); @@ -318,6 +347,7 @@ const decodeGitLabRepositoryCloneUrls = Schema.decodeEffect( const decodeGitLabDefaultBranch = Schema.decodeEffect( Schema.fromJsonString(RawGitLabDefaultBranchSchema), ); +const decodeGitLabAvatar = Schema.decodeEffect(Schema.fromJsonString(RawGitLabAvatarSchema)); const decodeGitLabNamespace = Schema.decodeEffect(Schema.fromJsonString(RawGitLabNamespaceSchema)); function normalizeRepositoryCloneUrls( @@ -387,6 +417,14 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +function gitLabHostname(baseUrl: string): string | null { + try { + return new URL(baseUrl).host; + } catch { + return null; + } +} + export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; @@ -437,6 +475,7 @@ export const make = Effect.gen(function* () { args: [ "mr", "list", + ...(input.repository ? ["--repo", input.repository] : []), "--source-branch", sourceRefName(input), ...stateArgs(input.state), @@ -517,6 +556,45 @@ export const make = Effect.gen(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + getCommitAvatarUrl: (input) => { + const email = input.email.trim(); + if (!email || !email.includes("@")) { + return Effect.succeed(null); + } + const hostname = gitLabHostname(input.providerBaseUrl); + const query = new URLSearchParams({ email }).toString(); + + return execute({ + cwd: input.cwd, + args: [ + "api", + ...(hostname && hostname !== "gitlab.com" ? ["--hostname", hostname] : []), + `avatar?${query}`, + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabAvatar(raw).pipe( + Effect.mapError( + (cause) => + new GitLabAvatarDecodeError({ + operation: "getCommitAvatarUrl", + command: "glab", + cwd: input.cwd, + cause, + }), + ), + ), + ), + // GitLab's official Avatar API may return external avatar-service URLs + // such as Gravatar/Libravatar. This is explicit opt-in behavior in the + // source-control settings, so pass through the provider response. + Effect.map((avatar) => { + const avatarUrl = avatar.avatar_url?.trim(); + return avatarUrl && avatarUrl.length > 0 ? avatarUrl : null; + }), + ); + }, createRepository: (input) => { const { namespacePath, projectPath } = parseRepositoryPath(input.repository); const namespaceId: Effect.Effect = namespacePath diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e066521..00ec726bb9b 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -121,6 +121,178 @@ it.effect("lists GitLab MRs through provider-neutral input names", () => }), ); +it.effect("sanitizes GitLab list-MR errors before transport", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + listMergeRequests: () => + Effect.fail( + new GitLabCli.GitLabCliCommandError({ + operation: "execute", + command: "glab", + cwd: "/repo", + cause: new Error( + "raw upstream https://secret:token@gitlab.example.test/group/repo?access_token=secret", + ), + }), + ), + }); + + const error = yield* provider + .listChangeRequests({ + cwd: "/repo", + headSelector: + "https://user:token@gitlab.example.test/group/repo?access_token=secret#fragment", + state: "open", + }) + .pipe(Effect.flip); + + assert.equal(error.provider, "gitlab"); + assert.equal(error.operation, "listChangeRequests"); + assert.equal(error.command, "glab"); + assert.equal(error.reference, "https://gitlab.example.test/group/repo"); + assert.equal(error.detail, "GitLab CLI command failed."); + assert.deepStrictEqual(error.cause, { + _tag: "GitLabCliCommandError", + name: "GitLabCliCommandError", + command: "glab", + operation: "execute", + detail: "GitLab CLI command failed.", + message: "GitLab CLI failed in execute: GitLab CLI command failed.", + }); + }), +); + +it.effect("lists GitLab MRs against the requested remote repository context", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/subgroup/repo.git", + }, + headSelector: "feature/provider", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: "https://gitlab.example.test/group/subgroup/repo", + state: "all", + limit: 10, + }); + }), +); + +it.effect("does not duplicate a self-hosted GitLab base path", () => + Effect.gen(function* () { + let listInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + listMergeRequests: (input) => { + listInput = input; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test/gitlab", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/gitlab/group/repo.git", + }, + headSelector: "feature/provider", + state: "open", + }); + + assert.deepStrictEqual(listInput, { + cwd: "/repo", + headSelector: "feature/provider", + repository: "https://gitlab.example.test/gitlab/group/repo", + state: "open", + }); + }), +); + +it.effect("uses GitLab Avatar API results for commit author avatars", () => + Effect.gen(function* () { + let avatarInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + getCommitAvatarUrl: (input) => { + avatarInput = input; + return Effect.succeed( + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + context: { + provider: { + kind: "gitlab", + name: "GitLab Self-Hosted", + baseUrl: "https://gitlab.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://gitlab.example.test/group/subgroup/repo.git", + }, + sha: "abc123", + authorEmail: "author@example.test", + }); + + assert.strictEqual( + avatarUrl, + "https://gitlab.example.test/uploads/-/system/user/avatar/123/avatar.png", + ); + assert.deepStrictEqual(avatarInput, { + cwd: "/repo", + email: "author@example.test", + providerBaseUrl: "https://gitlab.example.test", + }); + }), +); + +it.effect("does not call the GitLab Avatar API without a commit author email", () => + Effect.gen(function* () { + let avatarLookupCount = 0; + const provider = yield* makeProvider({ + getCommitAvatarUrl: () => { + avatarLookupCount += 1; + return Effect.succeed("https://gitlab.example.test/avatar.png"); + }, + }); + + const avatarUrl = yield* provider.getCommitAvatarUrl({ + cwd: "/repo", + sha: "abc123", + authorEmail: null, + }); + + assert.strictEqual(avatarUrl, null); + assert.strictEqual(avatarLookupCount, 0); + }), +); + it.effect("creates GitLab MRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: Parameters[0] | null = diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b3f..3c23956fdec 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -87,6 +87,29 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn } as const; } +function repositoryFromContext( + context: SourceControlProvider.SourceControlProviderContext | undefined, +): string | undefined { + if (!context) return undefined; + const repository = SourceControlProvider.repositoryPathFromRemoteUrl(context.remoteUrl); + if (!repository) return undefined; + try { + const baseUrl = new URL(context.provider.baseUrl); + if (baseUrl.hostname.toLowerCase() === "gitlab.com") return repository; + const basePath = decodeURIComponent(baseUrl.pathname).replace(/^\/+|\/+$/gu, ""); + const relativeRepository = + basePath && (repository === basePath || repository.startsWith(`${basePath}/`)) + ? repository.slice(basePath.length).replace(/^\/+/u, "") + : repository; + baseUrl.pathname = [basePath, relativeRepository].filter(Boolean).join("/"); + baseUrl.search = ""; + baseUrl.hash = ""; + return baseUrl.toString(); + } catch { + return repository; + } +} + export const discovery = { type: "cli", kind: "gitlab", @@ -107,29 +130,26 @@ export const make = Effect.gen(function* () { kind: "gitlab", listChangeRequests: (input) => { const source = SourceControlProvider.sourceControlRefFromInput(input); + const repository = repositoryFromContext(input.context); return gitlab .listMergeRequests({ cwd: input.cwd, headSelector: input.headSelector, ...(source ? { source } : {}), + ...(repository ? { repository } : {}), state: input.state, ...(input.limit !== undefined ? { limit: input.limit } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "gitlab", - operation: "listChangeRequests", - command: error.command, - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.detail, - cause: error, - }), + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "gitlab", + operation: "listChangeRequests", + cwd: input.cwd, + reference: input.headSelector, + error, + }), ), ); }, @@ -197,6 +217,30 @@ export const make = Effect.gen(function* () { }), ), ), + getCommitAvatarUrl: (input) => { + const authorEmail = input.authorEmail?.trim(); + if (!authorEmail) { + return Effect.succeed(null); + } + + return gitlab + .getCommitAvatarUrl({ + cwd: input.cwd, + email: authorEmail, + providerBaseUrl: input.context?.provider.baseUrl ?? "https://gitlab.com", + }) + .pipe( + Effect.mapError((error) => + SourceControlProvider.sourceControlProviderError({ + provider: "gitlab", + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: input.sha, + error, + }), + ), + ); + }, createRepository: (input) => gitlab.createRepository(input).pipe( Effect.mapError( diff --git a/apps/server/src/sourceControl/SourceControlPanelActions.ts b/apps/server/src/sourceControl/SourceControlPanelActions.ts new file mode 100644 index 00000000000..4acc8e8d577 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelActions.ts @@ -0,0 +1,878 @@ +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import type * as PlatformError from "effect/PlatformError"; +import { + GitCommandError, + type VcsPanelCompareInput, + type VcsPanelCompareResult, +} from "@t3tools/contracts"; + +import type { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; +import type { ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../git/remoteRefs.ts"; +import { parsePathLines, uniquePaths } from "./SourceControlPanelParsers.ts"; +import type { SourceControlPanelService } from "./SourceControlPanelService.ts"; + +type ActionMethodName = + | "stageFiles" + | "unstageFiles" + | "discardFiles" + | "readFileDiff" + | "commitStaged" + | "pullBranch" + | "pushBranch" + | "deleteBranch" + | "undoLatestCommit" + | "revertCommit" + | "checkoutCommit" + | "createBranchFromCommit" + | "mergeBranchIntoCurrent" + | "rebaseCurrentOnto" + | "fetchBranch" + | "fetchRemote" + | "addRemote" + | "removeRemote" + | "createStash" + | "applyStash" + | "popStash" + | "dropStash" + | "compare"; + +interface RunOptions { + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; +} +type Run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: RunOptions, +) => Effect.Effect; +type TemporaryIndex = ( + input: { + readonly cwd: string; + readonly paths: readonly string[]; + readonly operations: { + readonly gitIndexPath: string; + readonly tempIndexReadTree: string; + readonly tempIndexIntentToAdd: string; + }; + }, + use: (env: NodeJS.ProcessEnv) => Effect.Effect, +) => Effect.Effect; +type SelectedIndex = ( + cwd: string, + paths: readonly string[], + use: (env: NodeJS.ProcessEnv) => Effect.Effect, +) => Effect.Effect; + +export interface SourceControlPanelActionDependencies { + readonly run: Run; + readonly withTemporaryIntentToAddIndex: TemporaryIndex; + readonly withTemporarySelectedIndex: SelectedIndex; + readonly generatedCommitMessage: ( + cwd: string, + paths?: readonly string[], + env?: NodeJS.ProcessEnv, + ) => Effect.Effect; + readonly generatedStashMessage: ( + cwd: string, + mode: "all" | "staged" | "unstaged", + paths: readonly string[], + ) => Effect.Effect; + readonly upstreamForRef: ( + cwd: string, + refName: string, + ) => Effect.Effect; + readonly refExists: ( + operation: string, + cwd: string, + refName: string, + ) => Effect.Effect; + readonly snapshot: SourceControlPanelService["Service"]["snapshot"]; + readonly workflow: GitWorkflowService["Service"]; +} + +const COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL = + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again."; +const COMMIT_HOOK_FAILURE_DETAIL = + "The Git pre-commit hook failed. Run the repository pre-commit hook in a terminal for details."; +type CommitFailureHint = "hook-failed" | "native-dependency"; + +function commitFailureHintFromOutputLine(line: string): CommitFailureHint | null { + if ( + line.includes("Cannot find native binding") || + line.includes("Cannot find module 'vite-plus/binding'") || + line.includes('Cannot find module "vite-plus/binding"') + ) + return "native-dependency"; + return line.includes("VITE+ - pre-commit script failed") ? "hook-failed" : null; +} + +function commitFailureDetail(hint: CommitFailureHint | null): string | null { + return hint === "native-dependency" + ? COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL + : hint === "hook-failed" + ? COMMIT_HOOK_FAILURE_DETAIL + : null; +} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function gitError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +) { + return new GitCommandError({ + operation, + cwd, + command: commandLabel(args), + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +const isGitCommandError = Schema.is(GitCommandError); + +function detailFromUnknown(cause: unknown): string { + if (cause instanceof Error) return cause.message; + return typeof cause === "string" ? cause : "Git command failed."; +} + +function asGitCommandError(operation: string, cwd: string, args: readonly string[]) { + return (cause: unknown) => + isGitCommandError(cause) + ? cause + : gitError(operation, cwd, args, detailFromUnknown(cause), sanitizeErrorCause(cause)); +} + +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; + } +} + +function resolveRemoteBranchRef(refName: string, remoteNamesByLength: readonly string[]) { + return parseRemoteRefWithRemoteNames(refName, remoteNamesByLength); +} + +export function makeSourceControlPanelActions( + deps: SourceControlPanelActionDependencies, +): Pick { + const { + generatedCommitMessage, + generatedStashMessage, + refExists, + run, + snapshot, + upstreamForRef, + withTemporaryIntentToAddIndex, + withTemporarySelectedIndex, + workflow, + } = deps; + const stageFiles: SourceControlPanelService["Service"]["stageFiles"] = (input) => + run("vcs.panel.stageFiles", input.cwd, [ + "--literal-pathspecs", + "add", + "-A", + "--", + ...input.paths, + ]).pipe(Effect.asVoid); + + const unstageFiles: SourceControlPanelService["Service"]["unstageFiles"] = (input) => + run("vcs.panel.unstageFiles", input.cwd, [ + "--literal-pathspecs", + "reset", + "--", + ...input.paths, + ]).pipe(Effect.asVoid); + + const discardFiles: SourceControlPanelService["Service"]["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, + ["--literal-pathspecs", "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, [ + "--literal-pathspecs", + "restore", + "--staged", + "--worktree", + "--source=HEAD", + "--", + ...pathsInHead, + ]).pipe(Effect.asVoid); + } + if (pathsOutsideHead.length > 0) { + yield* run("vcs.panel.discardStagedFiles.reset", input.cwd, [ + "--literal-pathspecs", + "reset", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + yield* run("vcs.panel.discardStagedFiles.clean", input.cwd, [ + "--literal-pathspecs", + "clean", + "-fd", + "--", + ...pathsOutsideHead, + ]).pipe(Effect.asVoid); + } + return; + } + + const trackedPaths = yield* run("vcs.panel.discardUnstagedFiles.listIndexPaths", input.cwd, [ + "--literal-pathspecs", + "ls-files", + "--cached", + "--", + ...paths, + ]).pipe(Effect.map(parsePathLines)); + if (trackedPaths.length > 0) { + yield* run("vcs.panel.discardUnstagedFiles", input.cwd, [ + "--literal-pathspecs", + "restore", + "--worktree", + "--", + ...trackedPaths, + ]).pipe(Effect.asVoid); + } + yield* run("vcs.panel.cleanUntrackedFiles", input.cwd, [ + "--literal-pathspecs", + "clean", + "-fd", + "--", + ...paths, + ]).pipe(Effect.asVoid); + }); + + const readFileDiff: SourceControlPanelService["Service"]["readFileDiff"] = Effect.fn( + "readFileDiff", + )(function* (input) { + const source = input.source ?? { + kind: "working-tree" as const, + staged: input.staged ?? false, + }; + const diffPaths = uniquePaths( + input.originalPath ? [input.originalPath, input.path] : [input.path], + ); + if (source.kind === "commit") { + const patch = yield* run("vcs.panel.readCommitFileDiff", input.cwd, [ + "show", + "--format=", + "--no-ext-diff", + "--patch", + "--minimal", + source.sha, + "--", + ...diffPaths, + ]); + 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}`, + "--", + ...diffPaths, + ]); + return { path: input.path, staged: false, patch }; + } + if (source.kind === "stash") { + let patch = yield* run("vcs.panel.readStashFileDiff", input.cwd, [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + `${source.stashRef}^1`, + source.stashRef, + "--", + ...diffPaths, + ]); + if (patch.trim().length === 0) { + patch = yield* run( + "vcs.panel.readStashUntrackedFileDiff", + input.cwd, + [ + "show", + "--format=", + "--no-ext-diff", + "--patch", + "--minimal", + `${source.stashRef}^3`, + "--", + ...diffPaths, + ], + { allowNonZeroExit: true }, + ); + } + return { path: input.path, staged: false, patch }; + } + + const args = source.staged + ? [ + "diff", + "--cached", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + "--", + ...diffPaths, + ] + : ["diff", "--no-ext-diff", "--patch", "--minimal", "--find-renames=20%", "--", ...diffPaths]; + let patch = + !source.staged && input.originalPath + ? yield* withTemporaryIntentToAddIndex( + { + cwd: input.cwd, + paths: [input.path], + operations: { + gitIndexPath: "vcs.panel.readFileDiff.gitIndexPath", + tempIndexReadTree: "vcs.panel.readFileDiff.tempIndexReadTree", + tempIndexIntentToAdd: "vcs.panel.readFileDiff.tempIndexIntentToAdd", + }, + }, + (env) => run("vcs.panel.readFileDiff", input.cwd, args, { env }), + ).pipe(Effect.catch(() => run("vcs.panel.readFileDiff", input.cwd, args))) + : yield* run("vcs.panel.readFileDiff", input.cwd, args); + if (!source.staged && !input.originalPath && 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 = publishRemoteName ? "" : ((yield* upstreamForRef(cwd, branchName)) ?? ""); + const remoteNames = + upstream.length > 0 + ? yield* run("vcs.panel.pushBranch.remotes", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.orElseSucceed((): readonly string[] => []), + ) + : []; + const parsedUpstream = + upstream.length > 0 ? parseRemoteRefWithRemoteNames(upstream, remoteNames) : null; + const fallbackUpstreamParts = parsedUpstream ? [] : upstream.split("/"); + const upstreamRemoteName = parsedUpstream?.remoteName ?? fallbackUpstreamParts[0] ?? "origin"; + const upstreamBranchName = + (parsedUpstream?.branchName ?? fallbackUpstreamParts.slice(1).join("/")) || branchName; + const hasSameNameUpstream = upstream.length > 0 && upstreamBranchName === branchName; + const remoteName = publishRemoteName ?? (hasSameNameUpstream ? upstreamRemoteName : "origin"); + const remoteBranchName = hasSameNameUpstream ? upstreamBranchName : branchName; + yield* run("vcs.panel.pushBranch", cwd, [ + "push", + ...(force ? ["--force-with-lease"] : []), + "-u", + remoteName, + `${branchName}:refs/heads/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }); + + const runCommit = Effect.fn("runCommit")(function* ( + cwd: string, + message: string, + env?: NodeJS.ProcessEnv, + ) { + const args = ["commit", "-m", message] as const; + let failureHint: CommitFailureHint | null = null; + const recordFailureHint = (line: string) => + Effect.sync(() => { + const nextHint = commitFailureHintFromOutputLine(line); + if (nextHint !== null && (nextHint === "native-dependency" || failureHint === null)) { + failureHint = nextHint; + } + }); + + yield* run("vcs.panel.commitStaged", cwd, args, { + ...(env === undefined ? {} : { env }), + progress: { + onStdoutLine: recordFailureHint, + onStderrLine: recordFailureHint, + }, + }).pipe( + Effect.mapError((error) => { + const detail = commitFailureDetail(failureHint); + return detail === null + ? error + : gitError("vcs.panel.commitStaged", cwd, args, detail, error); + }), + Effect.asVoid, + ); + }); + + const commitStaged: SourceControlPanelService["Service"]["commitStaged"] = Effect.fn( + "commitStaged", + )(function* (input) { + const paths = uniquePaths(input.paths ?? []); + if (paths.length > 0) { + yield* withTemporarySelectedIndex(input.cwd, paths, (env) => + Effect.gen(function* () { + const message = + input.message?.trim() || (yield* generatedCommitMessage(input.cwd, paths, env)); + yield* runCommit(input.cwd, message, env); + }), + ); + const indexSyncExit = yield* Effect.exit(stageFiles({ cwd: input.cwd, paths })); + if (Exit.isFailure(indexSyncExit)) { + yield* Effect.logWarning("Selected-file commit index synchronization failed after commit", { + cwd: input.cwd, + pathCount: paths.length, + cause: indexSyncExit.cause, + }); + } + } else { + const message = input.message?.trim() || (yield* generatedCommitMessage(input.cwd)); + yield* runCommit(input.cwd, message); + } + 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: SourceControlPanelService["Service"]["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 remoteOutput = yield* run("vcs.panel.pullBranch.remotes", input.cwd, ["remote"]); + const resolvedUpstream = resolveRemoteBranchRef(upstream, parseRemoteNames(remoteOutput)); + if (!resolvedUpstream) { + 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", + resolvedUpstream.remoteName, + `${input.force ? "+" : ""}refs/heads/${resolvedUpstream.branchName}: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: SourceControlPanelService["Service"]["pushBranch"] = Effect.fn("pushBranch")( + function* (input) { + yield* pushBranchDirect(input.cwd, input.branchName, input.force ?? false, input.remoteName); + }, + ); + + const fetchBranch: SourceControlPanelService["Service"]["fetchBranch"] = Effect.fn("fetchBranch")( + function* (input) { + const remoteOutput = yield* run("vcs.panel.fetchBranch.remotes", input.cwd, ["remote"]); + const gitOrderRemoteNames = parseRemoteNamesInGitOrder(remoteOutput); + const sortedRemoteNames = parseRemoteNames(remoteOutput); + // Local branches intentionally win over same-named remote refs. + const isLocalBranch = yield* refExists( + "vcs.panel.fetchBranch.localBranch", + input.cwd, + `refs/heads/${input.branchName}`, + ); + const parsedRemoteBranch = isLocalBranch + ? null + : parseRemoteRefWithRemoteNames(input.branchName, sortedRemoteNames); + const isRemoteBranch = parsedRemoteBranch + ? yield* refExists( + "vcs.panel.fetchBranch.remoteBranch", + input.cwd, + `refs/remotes/${parsedRemoteBranch.remoteRef}`, + ) + : false; + const upstream = + isRemoteBranch && parsedRemoteBranch + ? parsedRemoteBranch.remoteRef + : yield* upstreamForRef(input.cwd, input.branchName); + const resolvedUpstream = upstream + ? resolveRemoteBranchRef(upstream, sortedRemoteNames) + : null; + if (upstream && !resolvedUpstream) { + return yield* gitError( + "vcs.panel.fetchBranch", + input.cwd, + ["fetch"], + `Branch ${input.branchName} has invalid upstream ${upstream}.`, + ); + } + const remoteName = resolvedUpstream?.remoteName ?? gitOrderRemoteNames[0] ?? "origin"; + const remoteBranchName = resolvedUpstream?.branchName ?? input.branchName; + yield* run("vcs.panel.fetchBranch", input.cwd, [ + "fetch", + remoteName, + `refs/heads/${remoteBranchName}:refs/remotes/${remoteName}/${remoteBranchName}`, + ]).pipe(Effect.asVoid); + }, + ); + + const deleteBranch: SourceControlPanelService["Service"]["deleteBranch"] = Effect.fn( + "deleteBranch", + )(function* (input) { + const panelSnapshot = yield* snapshot({ cwd: input.cwd }); + const localBranch = panelSnapshot.localBranches.find( + (branch) => branch.name === input.branchName, + ); + if (localBranch?.current) { + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", "-d", input.branchName], + "Cannot delete the current branch.", + ); + } + if (localBranch?.worktreePath) { + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", "-d", input.branchName], + "Cannot delete a branch that is checked out in another worktree.", + ); + } + if (localBranch) { + yield* run("vcs.panel.deleteLocalBranch", input.cwd, [ + "branch", + input.force ? "-D" : "-d", + input.branchName, + ]).pipe(Effect.asVoid); + return; + } + const remoteBranch = panelSnapshot.remotes.flatMap((remote) => + remote.branches + .filter( + (branch) => + branch.fullRefName === input.branchName || + `${remote.name}/${branch.name}` === input.branchName, + ) + .map((branch) => ({ remoteName: remote.name, branchName: branch.name })), + )[0]; + if (remoteBranch) { + yield* run("vcs.panel.deleteRemoteBranch", input.cwd, [ + "push", + remoteBranch.remoteName, + "--delete", + remoteBranch.branchName, + ]).pipe(Effect.asVoid); + return; + } + return yield* gitError( + "vcs.panel.deleteBranch", + input.cwd, + ["branch", input.force ? "-D" : "-d", input.branchName], + `Branch ${input.branchName} was not found in the current source-control snapshot.`, + ); + }); + + const undoLatestCommit: SourceControlPanelService["Service"]["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: SourceControlPanelService["Service"]["revertCommit"] = (input) => + run("vcs.panel.revertCommit", input.cwd, ["revert", "--no-edit", input.sha]).pipe( + Effect.asVoid, + ); + + const checkoutCommit: SourceControlPanelService["Service"]["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: SourceControlPanelService["Service"]["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: SourceControlPanelService["Service"]["mergeBranchIntoCurrent"] = ( + input, + ) => + run("vcs.panel.mergeBranchIntoCurrent", input.cwd, [ + "merge", + "--no-edit", + "--", + input.refName, + ]).pipe(Effect.asVoid); + + const rebaseCurrentOnto: SourceControlPanelService["Service"]["rebaseCurrentOnto"] = (input) => + run("vcs.panel.rebaseCurrentOnto", input.cwd, ["rebase", "--", input.refName]).pipe( + Effect.asVoid, + ); + + return { + stageFiles, + unstageFiles, + discardFiles, + 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), + 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, [ + ...(paths.length > 0 ? ["--literal-pathspecs"] : []), + "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 reverse = input.left.kind === "working-tree" && input.right.kind !== "working-tree"; + const args = range + ? [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + ...(reverse ? ["--reverse"] : []), + range, + ] + : ["diff"]; + return run("vcs.panel.compare", input.cwd, args).pipe( + Effect.map((patch): VcsPanelCompareResult => ({ patch })), + ); + }, + }; +} diff --git a/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts b/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts new file mode 100644 index 00000000000..cd78b2c460f --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelParsers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { parseCommits } from "./SourceControlPanelParsers.ts"; + +describe("SourceControlPanelParsers", () => { + it("preserves empty and tab-containing commit subjects", () => { + expect( + parseCommits( + [ + "full-empty\tshort-empty\tAda\tada@example.test\t2026-07-26T12:00:00Z\t", + "full-tabs\tshort-tabs\tGrace\tgrace@example.test\t2026-07-26T13:00:00Z\tSubject\twith\ttabs", + ].join("\n"), + ), + ).toEqual([ + { + sha: "full-empty", + shortSha: "short-empty", + message: "", + authorName: "Ada", + authorEmail: "ada@example.test", + authorAvatarUrl: null, + authoredAt: "2026-07-26T12:00:00Z", + headRefs: [], + tags: [], + files: [], + }, + { + sha: "full-tabs", + shortSha: "short-tabs", + message: "Subject\twith\ttabs", + authorName: "Grace", + authorEmail: "grace@example.test", + authorAvatarUrl: null, + authoredAt: "2026-07-26T13:00:00Z", + headRefs: [], + tags: [], + files: [], + }, + ]); + }); + + it("still rejects truncated commit records", () => { + expect(parseCommits("full\tshort")).toEqual([]); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelParsers.ts b/apps/server/src/sourceControl/SourceControlPanelParsers.ts new file mode 100644 index 00000000000..685cb93361b --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelParsers.ts @@ -0,0 +1,367 @@ +import type { + VcsPanelFileChange, + VcsPanelFileStatus, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsRef, +} from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { parseCount, readNulField, statusFromCode } from "./SourceControlPanelStatusParsers.ts"; + +export interface WorktreeBranchEntry { + readonly branchName: string; + readonly worktreePath: string; +} +export 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: [], + })); +} + +export 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); +} + +export 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, + }, + ]; + }); +} + +export function providerContextForRemote( + remote: VcsPanelRemote, +): SourceControlProvider.SourceControlProviderContext | null { + if (!remote.provider || remote.provider.kind === "unknown" || !remote.fetchUrl) { + return null; + } + return { + provider: remote.provider, + remoteName: remote.name, + remoteUrl: remote.fetchUrl, + }; +} + +export 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, + }; +} + +export 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, + }; +} + +export function parseWorktreeBranchEntries(output: string): WorktreeBranchEntry[] { + const entries: WorktreeBranchEntry[] = []; + let currentPath: string | null = null; + + for (const rawLine of output.split(/\r?\n/u)) { + const line = rawLine.trimEnd(); + if (line.length === 0) { + currentPath = null; + continue; + } + if (line.startsWith("worktree ")) { + currentPath = line.slice("worktree ".length); + continue; + } + if (currentPath && line.startsWith("branch refs/heads/")) { + entries.push({ + branchName: line.slice("branch refs/heads/".length), + worktreePath: currentPath, + }); + } + } + + return entries; +} + +export function parseWorktreeBranchPaths(output: string): Map { + return new Map( + parseWorktreeBranchEntries(output).map((entry) => [entry.branchName, entry.worktreePath]), + ); +} + +export function parseLocalBranches( + output: string, + worktreeBranchPaths: ReadonlyMap = new Map(), + statusDefaultBranchName: string | null = null, +): VcsRef[] { + const rows = output + .split(/\r?\n/u) + .filter((line) => line.trimEnd().length > 0) + .map((line) => { + // Preserve trailing tabs so empty upstream track columns stay aligned. + const columns = line.split("\t"); + const [name = "", head = ""] = columns; + const hasInlineWorktreePath = columns.length >= 6; + const worktreePath = hasInlineWorktreePath ? (columns[2] ?? "") : ""; + const lastActivityAt = hasInlineWorktreePath ? (columns[3] ?? "") : (columns[2] ?? ""); + const upstreamName = hasInlineWorktreePath ? (columns[4] ?? "") : (columns[3] ?? ""); + const track = hasInlineWorktreePath ? (columns[5] ?? "") : (columns[4] ?? ""); + const { aheadCount, behindCount } = parseBranchTrackCounts(track); + const resolvedWorktreePath = worktreeBranchPaths.get(name) ?? worktreePath; + return { + name, + current: head.trim() === "*", + worktreePath: resolvedWorktreePath.length > 0 ? resolvedWorktreePath : null, + lastActivityAt: lastActivityAt.length > 0 ? lastActivityAt : null, + upstreamName: upstreamName.length > 0 ? upstreamName : null, + aheadCount, + behindCount, + }; + }) + .filter((branch) => branch.name.length > 0); + const defaultName = + (statusDefaultBranchName !== null + ? rows.find((branch) => branch.name === statusDefaultBranchName)?.name + : null) ?? + 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); +} + +export 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; +} + +export 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); +} + +export function parsePathLines(output: string): string[] { + return output.split(/\r?\n/u).filter((line) => line.length > 0); +} + +export function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +export 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; +} + +export function parseCommits(output: string): VcsPanelSnapshotResult["recentCommits"] { + return output.split("\n").flatMap((line) => { + const fields = line.split("\t"); + if (fields.length < 6) return []; + const [sha, shortSha, authorName, authorEmail, authoredAt, ...messageParts] = fields; + if (!sha || !shortSha) return []; + const message = messageParts.join("\t"); + return [ + { + sha, + shortSha, + message, + authorName: authorName ?? null, + authorEmail: authorEmail ?? null, + authorAvatarUrl: null, + authoredAt: authoredAt ?? null, + headRefs: [], + tags: [], + files: [], + }, + ]; + }); +} + +export 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"); +} + +export 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; +} + +export 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); + const resolvedOriginalPath = status?.originalPath ?? originalPath; + files.push({ + path, + originalPath: resolvedOriginalPath, + status: status?.status ?? (resolvedOriginalPath ? "renamed" : "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, oldPathRaw, newPathRaw] = line.split("\t"); + const path = newPathRaw ?? oldPathRaw; + if (!path) continue; + const status = input.statuses?.get(path); + const originalPath = status?.originalPath ?? (newPathRaw ? (oldPathRaw ?? null) : null); + files.push({ + path, + originalPath, + status: status?.status ?? (originalPath ? "renamed" : "modified"), + insertions: parseCount(insertionsRaw), + deletions: parseCount(deletionsRaw), + }); + } + return files.toSorted((left, right) => left.path.localeCompare(right.path)); +} diff --git a/apps/server/src/sourceControl/SourceControlPanelReaders.ts b/apps/server/src/sourceControl/SourceControlPanelReaders.ts new file mode 100644 index 00000000000..3f9ecf8b944 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelReaders.ts @@ -0,0 +1,854 @@ +import * as Effect from "effect/Effect"; +import type { + ChangeRequest, + GitCommandError, + VcsPanelActionableForkBranch, + VcsPanelBranchCommitsInput, + VcsPanelBranchCommitsResult, + VcsPanelBranchDetails, + VcsPanelChangeGroup, + VcsPanelSnapshotResult, + VcsPanelRemote, + VcsPanelStashDetails, + VcsRef, + SourceControlProviderKind, +} from "@t3tools/contracts"; + +import type { ServerSettingsService } from "../serverSettings.ts"; +import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import type { ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import { parseRemoteRefWithRemoteNames } from "../git/remoteRefs.ts"; +import type { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { + parseAheadBehindCounts, + branchActivityTime, + parseCommits, + parseCreatedFromRef, + parseFileChangesFromNumstat, + parseNameStatus, + parseRemoteVerbose, + providerContextForRemote, +} from "./SourceControlPanelParsers.ts"; +import { parseNumstat, parsePorcelainStatus } from "./SourceControlPanelStatusParsers.ts"; + +type ConfiguredSourceControlProviderKind = Exclude; + +function isConfiguredSourceControlProviderKind( + kind: SourceControlProviderKind, +): kind is ConfiguredSourceControlProviderKind { + return kind !== "unknown"; +} + +type Run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, +) => Effect.Effect; + +export interface SourceControlPanelReaderDependencies { + readonly run: Run; + readonly serverSettings: ServerSettingsService["Service"]; + readonly sourceControlProviders: SourceControlProviderRegistry["Service"] | undefined; + readonly textGeneration: TextGeneration["Service"] | undefined; +} + +export function makeSourceControlPanelReaders(deps: SourceControlPanelReaderDependencies) { + const { run, serverSettings, sourceControlProviders, textGeneration } = deps; + const COMMIT_PAGE_SIZE = 10; + + const readWorkingTreeChangeGroups = ( + cwd: string, + ): Effect.Effect< + { + readonly porcelain: string; + readonly changeGroups: VcsPanelChangeGroup[]; + }, + GitCommandError + > => + Effect.all( + [ + run("vcs.panel.statusPorcelain", cwd, ["status", "--porcelain=2", "--branch", "-uall"]), + run("vcs.panel.unstagedNumstat", cwd, ["diff", "--numstat", "-z", "--find-renames=20%"]), + run("vcs.panel.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + "-z", + "--find-renames=20%", + ]), + run("vcs.panel.stagedNameStatus", cwd, [ + "diff", + "--cached", + "--name-status", + "-z", + "--find-renames=20%", + ]), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([porcelain, unstagedNumstat, stagedNumstat, stagedNameStatus]) => { + const stagedFiles = parseFileChangesFromNumstat({ + numstat: stagedNumstat, + statuses: parseNameStatus(stagedNameStatus), + }); + return { + porcelain, + changeGroups: parsePorcelainStatus({ + status: porcelain, + stagedFiles, + stagedStats: parseNumstat(stagedNumstat), + unstagedStats: parseNumstat(unstagedNumstat), + untrackedStats: new Map(), + }), + }; + }), + ); + + const changeGroupsHaveFiles = (groups: readonly VcsPanelChangeGroup[]) => + groups.some((group) => group.files.length > 0); + + 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 commitAvatarProviderContexts = (cwd: string) => { + if (!sourceControlProviders) { + return Effect.succeed>([]); + } + + return Effect.all({ + settings: serverSettings.getSettings, + remotes: run("vcs.panel.commitAvatarRemotes", cwd, ["remote", "-v"]), + }).pipe( + Effect.map(({ settings, remotes }) => + parseRemoteVerbose(remotes) + .map(providerContextForRemote) + .filter( + (context): context is SourceControlProvider.SourceControlProviderContext => + context !== null, + ) + .filter( + (context) => + isConfiguredSourceControlProviderKind(context.provider.kind) && + settings.sourceControl.providers[context.provider.kind]?.showCommitAuthorAvatar === + true, + ), + ), + Effect.orElseSucceed(() => []), + ); + }; + + const providerAvatarUrlForCommit = ( + cwd: string, + registry: SourceControlProviderRegistry["Service"], + contexts: ReadonlyArray, + commit: VcsPanelSnapshotResult["recentCommits"][number], + ) => + Effect.gen(function* () { + for (const context of contexts) { + const provider = yield* registry + .get(context.provider.kind) + .pipe(Effect.orElseSucceed(() => null)); + if (!provider) continue; + const avatarUrl = yield* provider + .getCommitAvatarUrl({ + cwd, + context, + sha: commit.sha, + authorEmail: commit.authorEmail, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (avatarUrl) return avatarUrl; + } + return null; + }); + + const withCommitAvatars = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => { + if (commits.length === 0 || !sourceControlProviders) { + return Effect.succeed(commits); + } + + const registry = sourceControlProviders; + return commitAvatarProviderContexts(cwd).pipe( + Effect.flatMap((contexts) => { + if (contexts.length === 0) { + return Effect.succeed(commits); + } + + // Required and intended behavior: commit rows should show the source-control + // provider account avatar image when the provider exposes one. `null` is only + // the initials fallback path; do not replace this with generated avatars. + return Effect.forEach( + commits, + (commit) => + providerAvatarUrlForCommit(cwd, registry, contexts, commit).pipe( + Effect.map((authorAvatarUrl) => + authorAvatarUrl ? { ...commit, authorAvatarUrl } : commit, + ), + ), + { concurrency: 4 }, + ); + }), + Effect.orElseSucceed(() => commits), + ); + }; + + const withCommitDetails = (cwd: string, commits: VcsPanelSnapshotResult["recentCommits"]) => + withCommitAvatars(cwd, commits).pipe( + Effect.flatMap((commitsWithAvatars) => + commitRefsBySha(cwd, commitsWithAvatars).pipe( + Effect.flatMap((refsBySha) => + Effect.forEach( + commitsWithAvatars, + (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 => { + const uniqueForks = ( + forks: readonly VcsPanelActionableForkBranch[], + ): readonly VcsPanelActionableForkBranch[] => { + const byKey = new Map(); + for (const fork of forks) { + const key = `${fork.localBranchName}\0${fork.remoteRefName}`; + const existing = byKey.get(key); + if (!existing || fork.behindCount > existing.behindCount) { + byKey.set(key, fork); + } + } + return [...byKey.values()].toSorted((left, right) => { + const activity = branchActivityTime(right) - branchActivityTime(left); + return activity !== 0 + ? activity + : `${left.remoteName}/${left.remoteBranchName}`.localeCompare( + `${right.remoteName}/${right.remoteBranchName}`, + ); + }); + }; + + const candidates = + remotes.length < 2 + ? [] + : 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 })), + ), + ); + const sameNameForks = 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] : [])))); + + const pullRequestForks = Effect.forEach( + remotes, + (remote) => { + const context = providerContextForRemote(remote); + if (!context || !sourceControlProviders) { + return Effect.succeed([]); + } + + return sourceControlProviders.get(context.provider.kind).pipe( + Effect.flatMap((provider) => + Effect.forEach( + localBranches, + (localBranch) => + provider + .listChangeRequests({ + cwd, + context, + headSelector: localBranch.name, + state: "open", + limit: 20, + }) + .pipe( + Effect.flatMap((changeRequests) => + Effect.forEach( + changeRequests, + (changeRequest) => + actionableForkForChangeRequest(cwd, localBranch, remote, changeRequest), + { concurrency: 4 }, + ), + ), + Effect.map((forks) => forks.flatMap((fork) => (fork ? [fork] : []))), + Effect.orElseSucceed(() => []), + ), + { concurrency: 4 }, + ), + ), + Effect.map((forks) => forks.flat()), + Effect.orElseSucceed(() => []), + ); + }, + { concurrency: 2 }, + ).pipe(Effect.map((forks) => forks.flat())); + + return Effect.all([sameNameForks, pullRequestForks], { concurrency: "unbounded" }).pipe( + Effect.map(([forks, prForks]) => uniqueForks([...forks, ...prForks])), + Effect.orElseSucceed(() => []), + ); + }; + + const actionableForkForChangeRequest = ( + cwd: string, + localBranch: VcsRef, + remote: VcsPanelRemote, + changeRequest: ChangeRequest, + ): Effect.Effect => { + if (changeRequest.headRefName !== localBranch.name) return Effect.succeed(null); + const remoteBranch = remote.branches.find( + (branch) => branch.name === changeRequest.baseRefName, + ); + if (!remoteBranch) return Effect.succeed(null); + + return 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; + }).pipe(Effect.orElseSucceed(() => null)); + }; + + 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) + : mode === "all" + ? (["diff", "HEAD", "--stat"] as const) + : (["diff", "--stat"] as const); + const patchArgs = + mode === "staged" + ? (["diff", "--cached", "--no-ext-diff", "--patch", "--minimal"] as const) + : mode === "all" + ? (["diff", "HEAD", "--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 literalPathspecArgs = + paths && paths.length > 0 ? (["--literal-pathspecs"] as const) : []; + const [settings, summary, patch, status] = yield* Effect.all( + [ + serverSettings.getSettings, + run("vcs.panel.stashMessageSummary", cwd, [ + ...literalPathspecArgs, + ...diffArgs, + ...pathArgs, + ]), + run("vcs.panel.stashMessagePatch", cwd, [ + ...literalPathspecArgs, + ...patchArgs, + ...pathArgs, + ]), + run("vcs.panel.stashMessageStatus", cwd, [ + ...literalPathspecArgs, + "status", + "--short", + ...pathArgs, + ]), + ], + { 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 generatedCommitMessage = ( + cwd: string, + paths?: readonly string[], + env?: NodeJS.ProcessEnv, + ): Effect.Effect => + Effect.gen(function* () { + const fallback = "T3 Code changes"; + const pathArgs = paths && paths.length > 0 ? (["--", ...paths] as const) : []; + const literalPathspecArgs = pathArgs.length > 0 ? (["--literal-pathspecs"] as const) : []; + const commandOptions = env ? { env } : undefined; + const [settings, summary, patch] = yield* Effect.all( + [ + serverSettings.getSettings, + run( + "vcs.panel.commitMessageSummary", + cwd, + [...literalPathspecArgs, "diff", "--cached", "--stat", ...pathArgs], + commandOptions, + ), + run( + "vcs.panel.commitMessagePatch", + cwd, + [ + ...literalPathspecArgs, + "diff", + "--cached", + "--no-ext-diff", + "--patch", + "--minimal", + ...pathArgs, + ], + commandOptions, + ), + ], + { concurrency: "unbounded" }, + ); + if (!textGeneration) return fallback; + if (summary.trim().length === 0 && patch.trim().length === 0) return fallback; + const generated = yield* textGeneration.generateCommitMessage({ + cwd, + branch: null, + stagedSummary: summary.slice(0, 8_000), + stagedPatch: patch.slice(0, 50_000), + modelSelection: settings.textGenerationModelSelection, + }); + return generated.subject.trim() || fallback; + }).pipe(Effect.orElseSucceed(() => "T3 Code changes")); + + 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": + return Effect.succeed(baseRef ? `${baseRef}...${refName}` : refName); + 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 refExists = (operation: string, cwd: string, refName: string) => + run(operation, cwd, ["show-ref", "--verify", refName], { allowNonZeroExit: true }).pipe( + Effect.map((output) => output.trim().length > 0), + Effect.orElseSucceed(() => false), + ); + + 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 ?? defaultCompareRef; + 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, + }; + }); + + return { + actionableForkBranches, + actionableForkForChangeRequest, + branchCommits, + branchDetails, + changeGroupsHaveFiles, + generatedCommitMessage, + generatedStashMessage, + readWorkingTreeChangeGroups, + refExists, + stashDetails, + upstreamForRef, + withCommitDetails, + }; +} diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts new file mode 100644 index 00000000000..22458263c8f --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Branches.test.ts @@ -0,0 +1,833 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +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, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : 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 GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +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("pulls non-current branches from upstream remotes with slashes in their name", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pullBranch({ + cwd: "/repo", + branchName: "main", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "team/upstream", + "refs/heads/main:refs/heads/main", + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("team/upstream/main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\nteam/upstream\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("rejects slashful upstreams that do not match a known remote", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .pullBranch({ + cwd: "/repo", + branchName: "main", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.pullBranch"); + assert.equal(error.detail, "Branch main has invalid upstream team/upstream/main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("team/upstream/main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("rejects slashless local upstreams when pulling non-current branches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .pullBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.pullBranch"); + assert.equal(error.detail, "Branch feature/source-control has invalid upstream main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.pullBranch.nonCurrent"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("main\n"); + case "vcs.panel.pullBranch.remotes": + return success("origin\nupstream\n"); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("fetches branches from upstream remotes with slashes in their name", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.fetchBranch({ + cwd: "/repo", + branchName: "team/upstream/main", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.fetchBranch"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "team/upstream", + "refs/heads/main:refs/remotes/team/upstream/main", + ]); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nteam/upstream\n"); + case "vcs.panel.fetchBranch.remoteBranch": + return success("abc123 refs/remotes/team/upstream/main\n"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("resolves local branch deletion from the server snapshot", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }); + + const deleteCall = calls.find((call) => call.operation === "vcs.panel.deleteLocalBranch"); + assert.deepStrictEqual(deleteCall?.args, ["branch", "-D", "feature/source-control"]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + "feature/source-control\t\t\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t", + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("rejects deletion of a branch checked out in another worktree", () => { + const calls: ExecuteGitInput[] = []; + const siblingWorktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .deleteBranch({ + cwd: "/repo", + branchName: "feature/source-control", + force: true, + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.deleteBranch"); + assert.equal(error.detail, "Cannot delete a branch that is checked out in another worktree."); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.deleteLocalBranch")); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + `feature/source-control\t\t${siblingWorktreePath}\t2026-06-20T12:00:00.000Z\torigin/feature/source-control\t`, + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("resolves remote branch deletion from the server snapshot", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.deleteBranch({ + cwd: "/repo", + branchName: "origin/feature/source-control", + }); + + const deleteCall = calls.find((call) => call.operation === "vcs.panel.deleteRemoteBranch"); + assert.deepStrictEqual(deleteCall?.args, [ + "push", + "origin", + "--delete", + "feature/source-control", + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success(""); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@example.test:fork/repo.git\t(fetch)", + "origin\tgit@example.test:fork/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return success("origin/feature/source-control\t2026-06-20T12:00:00.000Z\n"); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => Effect.succeed(localStatus), + }, + ), + ), + ); + }); + + it.effect("fetches local branches with remote-looking names from their upstream", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.fetchBranch({ + cwd: "/repo", + branchName: "origin/feature", + }); + + const fetchCall = calls.find((call) => call.operation === "vcs.panel.fetchBranch"); + assert.deepStrictEqual(fetchCall?.args, [ + "fetch", + "upstream", + "refs/heads/main:refs/remotes/upstream/main", + ]); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.fetchBranch.remoteBranch"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nupstream\n"); + case "vcs.panel.fetchBranch.localBranch": + return success("abc123 refs/heads/origin/feature\n"); + case "vcs.panel.branchUpstream": + return success("upstream/main\n"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("rejects slashless local upstreams when fetching local branches", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .fetchBranch({ + cwd: "/repo", + branchName: "feature/source-control", + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.fetchBranch"); + assert.equal(error.detail, "Branch feature/source-control has invalid upstream main."); + assert.equal( + calls.some((call) => call.operation === "vcs.panel.fetchBranch"), + false, + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.fetchBranch.remotes": + return success("origin\nupstream\n"); + case "vcs.panel.fetchBranch.localBranch": + return success("abc123 refs/heads/feature/source-control\n"); + case "vcs.panel.branchUpstream": + return success("main\n"); + 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("uses the repository default branch as the default compare ref even when current", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(snapshot.defaultCompareRef, "main"); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t*\t/repo\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t\t\t2026-06-19T12:00:00.000Z\t\t", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + it.effect("keeps a non-main current default branch as the default compare ref", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(snapshot.defaultCompareRef, "develop"); + assert.deepStrictEqual( + snapshot.localBranches.map((ref) => ({ name: ref.name, isDefault: ref.isDefault })), + [ + { name: "develop", isDefault: true }, + { name: "feature/source-control", isDefault: false }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "develop\t*\t/repo\t2026-06-20T12:00:00.000Z\torigin/develop\t", + "feature/source-control\t\t\t2026-06-19T12:00:00.000Z\t\t", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head develop"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "develop", + isDefaultRef: true, + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ), + ); + + 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, + }), + }, + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts new file mode 100644 index 00000000000..20595834900 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Core.test.ts @@ -0,0 +1,1158 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +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, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : 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 GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +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("keeps the default branch as its own stable comparison base", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const details = yield* service.branchDetails({ + cwd: "/repo", + branch: { + name: "develop", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + defaultCompareRef: "develop", + }); + + assert.equal(details.baseRef, "develop"); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => success(input.args[0] === "rev-list" ? "0" : "")), + ), + ), + ), + ); + + 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("uses the compare range for compare-history branch queries", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "compare-history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["rev-list", "--count", "main...feature/source-control"], + [ + "log", + "--skip=0", + "--max-count=10", + "--format=%H%x09%h%x09%an%x09%ae%x09%aI%x09%s", + "main...feature/source-control", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(input.args[0] === "rev-list" ? "0" : ""); + }), + ), + ), + ); + }); + + it.effect("uses the selected branch for compare-history queries without a base", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: null, + kind: "compare-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("does not fetch provider account avatar URLs by default", () => { + let avatarLookupCount = 0; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.equal(result.commits[0]?.authorAvatarUrl, null); + assert.equal(result.commits[1]?.authorAvatarUrl, null); + assert.equal(avatarLookupCount, 0); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.args[0]) { + case "rev-list": + return success("2"); + case "log": + return success( + [ + "a".repeat(40), + "aaaaaaa", + "Ada Lovelace", + "ada@example.test", + "2026-06-27T10:00:00+00:00", + "Add source control avatars", + ].join("\t") + + "\n" + + [ + "b".repeat(40), + "bbbbbbb", + "Grace Hopper", + "grace@example.test", + "2026-06-27T09:00:00+00:00", + "Keep avatars distinct", + ].join("\t"), + ); + case "remote": + return success( + [ + "origin\thttps://github.com/pingdotgg/t3code.git (fetch)", + "origin\thttps://github.com/pingdotgg/t3code.git (push)", + ].join("\n"), + ); + default: + return success(""); + } + }), + {}, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + getCommitAvatarUrl: () => + Effect.sync(() => { + avatarLookupCount += 1; + return "https://avatars.githubusercontent.com/u/101?v=4"; + }), + }), + }, + ), + ), + ); + }); + + it.effect("uses opted-in provider account avatar URLs for commit authors", () => { + const avatarLookups: Array<{ + readonly sha: string; + readonly authorEmail: string | null | undefined; + readonly remoteUrl: string | undefined; + }> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + const firstAvatar = result.commits[0]?.authorAvatarUrl; + const secondAvatar = result.commits[1]?.authorAvatarUrl; + + if (typeof firstAvatar !== "string" || typeof secondAvatar !== "string") { + assert.fail("expected commit authors to have provider avatar URLs"); + } + assert.equal(firstAvatar, "https://avatars.githubusercontent.com/u/101?v=4"); + assert.equal(secondAvatar, "https://avatars.githubusercontent.com/u/202?v=4"); + assert.notStrictEqual(firstAvatar, secondAvatar); + assert.deepStrictEqual(avatarLookups, [ + { + sha: "a".repeat(40), + authorEmail: "ada@example.test", + remoteUrl: "https://github.com/pingdotgg/t3code.git", + }, + { + sha: "b".repeat(40), + authorEmail: "grace@example.test", + remoteUrl: "https://github.com/pingdotgg/t3code.git", + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.args[0]) { + case "rev-list": + return success("2"); + case "log": + return success( + [ + "a".repeat(40), + "aaaaaaa", + "Ada Lovelace", + "ada@example.test", + "2026-06-27T10:00:00+00:00", + "Add source control avatars", + ].join("\t") + + "\n" + + [ + "b".repeat(40), + "bbbbbbb", + "Grace Hopper", + "grace@example.test", + "2026-06-27T09:00:00+00:00", + "Keep avatars distinct", + ].join("\t"), + ); + case "remote": + return success( + [ + "origin\thttps://github.com/pingdotgg/t3code.git (fetch)", + "origin\thttps://github.com/pingdotgg/t3code.git (push)", + ].join("\n"), + ); + default: + return success(""); + } + }), + {}, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + getCommitAvatarUrl: (input) => + Effect.sync(() => { + avatarLookups.push({ + sha: input.sha, + authorEmail: input.authorEmail, + remoteUrl: input.context?.remoteUrl, + }); + return input.sha.startsWith("a") + ? "https://avatars.githubusercontent.com/u/101?v=4" + : "https://avatars.githubusercontent.com/u/202?v=4"; + }), + }), + }, + { + sourceControl: { + providers: { + github: { + showCommitAuthorAvatar: true, + }, + }, + }, + }, + ), + ), + ); + }); + + it.effect("preserves sanitized causes when wrapping git execution failures", () => { + const cause = new Error("transport closed"); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }) + .pipe(Effect.flip); + + assert.strictEqual(isGitCommandError(error), true); + assert.strictEqual(error.detail, "transport closed"); + assert.deepStrictEqual(error.cause, { + name: "Error", + message: "transport closed", + }); + }).pipe( + Effect.provide( + makeTestLayer( + () => Effect.fail(cause) as unknown as Effect.Effect, + ), + ), + ); + }); + + 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), + [ + ["--literal-pathspecs", "ls-tree", "-r", "--name-only", "HEAD", "--", "new-file.ts"], + ["--literal-pathspecs", "reset", "--", "new-file.ts"], + ["--literal-pathspecs", "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), + [ + ["--literal-pathspecs", "ls-files", "--cached", "--", "tracked.ts", "new-file.ts"], + ["--literal-pathspecs", "restore", "--worktree", "--", "tracked.ts"], + ["--literal-pathspecs", "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("fails unstaged discard when tracked restore fails", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .discardFiles({ + cwd: "/repo", + paths: ["tracked.ts", "new-file.ts"], + staged: false, + }) + .pipe(Effect.flip); + + assert.equal(error.operation, "vcs.panel.discardUnstagedFiles"); + const relevantCalls = calls.filter((call) => + [ + "vcs.panel.discardUnstagedFiles.listIndexPaths", + "vcs.panel.discardUnstagedFiles", + "vcs.panel.cleanUntrackedFiles", + ].includes(call.operation), + ); + assert.deepStrictEqual( + relevantCalls.map((call) => [call.operation, call.args]), + [ + [ + "vcs.panel.discardUnstagedFiles.listIndexPaths", + ["--literal-pathspecs", "ls-files", "--cached", "--", "tracked.ts", "new-file.ts"], + ], + [ + "vcs.panel.discardUnstagedFiles", + ["--literal-pathspecs", "restore", "--worktree", "--", "tracked.ts"], + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + if (input.operation === "vcs.panel.discardUnstagedFiles.listIndexPaths") { + return success("tracked.ts\n"); + } + if (input.operation === "vcs.panel.discardUnstagedFiles") { + return failure("restore failed"); + } + return 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("does not abort a successful commit because of hook diagnostics", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + message: "Commit selected file", + }); + assert.deepStrictEqual( + calls.map((call) => call.args), + [["commit", "-m", "Commit selected file"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.gen(function* () { + calls.push(input); + yield* ( + input.progress?.onStderrLine?.("Error: Cannot find native binding") ?? Effect.void + ); + yield* ( + input.progress?.onStderrLine?.("VITE+ - pre-commit script failed (code 1)") ?? + Effect.void + ); + yield* Effect.yieldNow; + return success(); + }), + ), + ), + ); + }); + + it.effect("enriches a failed commit with detected hook diagnostics", () => { + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const error = yield* service + .commitStaged({ + cwd: "/repo", + message: "Commit selected file", + }) + .pipe(Effect.flip); + + assert.equal( + error.detail, + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again.", + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.gen(function* () { + yield* ( + input.progress?.onStderrLine?.("Error: Cannot find native binding") ?? Effect.void + ); + return failure("pre-commit hook failed"); + }), + ), + ), + ); + }); + + it.effect("stages and unstages selected files with literal pathspecs", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + const paths = ["src/[literal].ts"]; + + yield* service.stageFiles({ cwd: "/repo", paths }); + yield* service.unstageFiles({ cwd: "/repo", paths }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["--literal-pathspecs", "add", "-A", "--", "src/[literal].ts"], + ["--literal-pathspecs", "reset", "--", "src/[literal].ts"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("stashes selected files with literal pathspecs", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.createStash({ + cwd: "/repo", + paths: ["src/[literal].ts"], + includeUntracked: true, + message: "Save literal file", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "--literal-pathspecs", + "stash", + "push", + "--include-untracked", + "-m", + "Save literal file", + "--", + "src/[literal].ts", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("commits selected files through an isolated temporary index", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["src/mixed.ts"], + message: "Commit selected file", + }); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: ["--literal-pathspecs", "add", "-A", "--", "src/mixed.ts"], + }, + { + operation: "vcs.panel.commitStaged", + args: ["commit", "-m", "Commit selected file"], + }, + { + operation: "vcs.panel.stageFiles", + args: ["--literal-pathspecs", "add", "-A", "--", "src/mixed.ts"], + }, + ], + ); + const selectedIndexCalls = calls.filter((call) => + call.operation.startsWith("vcs.panel.commitStaged"), + ); + assert.isTrue(selectedIndexCalls.every((call) => Boolean(call.env?.GIT_INDEX_FILE?.length))); + assert.isUndefined(calls.at(-1)?.env?.GIT_INDEX_FILE); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("leaves the real index untouched when a selected-file commit fails", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service + .commitStaged({ + cwd: "/repo", + paths: ["src/mixed.ts"], + message: "Commit selected file", + }) + .pipe(Effect.flip); + + assert.deepStrictEqual( + calls.map((call) => call.operation), + [ + "vcs.panel.commitStaged.tempIndexResolveHead", + "vcs.panel.commitStaged.tempIndexReadTree", + "vcs.panel.commitStaged.tempIndexAddSelected", + "vcs.panel.commitStaged", + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged" + ? failure("commit failed") + : success(); + }), + ), + ), + ); + }); + + it.effect("reports success when real-index sync fails after a selected-file commit", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["src/[literal].ts"], + message: "Commit selected file", + }); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: ["--literal-pathspecs", "add", "-A", "--", "src/[literal].ts"], + }, + { + operation: "vcs.panel.commitStaged", + args: ["commit", "-m", "Commit selected file"], + }, + { + operation: "vcs.panel.stageFiles", + args: ["--literal-pathspecs", "add", "-A", "--", "src/[literal].ts"], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.stageFiles" + ? failure("index sync failed") + : success(); + }), + ), + ), + ); + }); + + it.effect("passes merge refs after a positional separator", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.mergeBranchIntoCurrent({ + cwd: "/repo", + refName: "feature/source-control", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["merge", "--no-edit", "--", "feature/source-control"]], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(); + }), + ), + ), + ); + }); + + it.effect("initializes an empty selected-file index for an unborn HEAD", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["README.md"], + message: "Initial commit", + }); + + assert.deepStrictEqual( + calls.slice(0, 3).map((call) => ({ + operation: call.operation, + args: call.args, + allowNonZeroExit: call.allowNonZeroExit, + })), + [ + { + operation: "vcs.panel.commitStaged.tempIndexResolveHead", + args: ["rev-parse", "--verify", "HEAD"], + allowNonZeroExit: true, + }, + { + operation: "vcs.panel.commitStaged.tempIndexReadTree", + args: ["read-tree", "--empty"], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.commitStaged.tempIndexAddSelected", + args: ["--literal-pathspecs", "add", "-A", "--", "README.md"], + allowNonZeroExit: false, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.commitStaged.tempIndexResolveHead" + ? failure("Needed a single revision") + : success(); + }), + ), + ), + ); + }); + + it.effect("reports success when post-commit index synchronization defects", () => { + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.commitStaged({ + cwd: "/repo", + paths: ["src/selected.ts"], + message: "Commit selected file", + }); + }).pipe( + Effect.provide( + makeTestLayer((input) => + input.operation === "vcs.panel.stageFiles" + ? Effect.die(new Error("index sync defect")) + : Effect.succeed(success()), + ), + ), + ); + }); + + it.effect("scopes generated stash input to literal selected paths", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.createStash({ + cwd: "/repo", + mode: "all", + paths: ["src/[literal].ts"], + includeUntracked: true, + }); + + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.stashMessageSummary", + args: ["--literal-pathspecs", "diff", "HEAD", "--stat", "--", "src/[literal].ts"], + }, + { + operation: "vcs.panel.stashMessagePatch", + args: [ + "--literal-pathspecs", + "diff", + "HEAD", + "--no-ext-diff", + "--patch", + "--minimal", + "--", + "src/[literal].ts", + ], + }, + { + operation: "vcs.panel.stashMessageStatus", + args: ["--literal-pathspecs", "status", "--short", "--", "src/[literal].ts"], + }, + { + operation: "vcs.panel.createStash", + args: [ + "--literal-pathspecs", + "stash", + "push", + "--include-untracked", + "-m", + "T3 Code all stash", + "--", + "src/[literal].ts", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success(""); + }), + ), + ), + ); + }); + + it.effect("reverses comparisons with the working tree on the left", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.compare({ + cwd: "/repo", + left: { kind: "working-tree" }, + right: { kind: "branch", refName: "feature/right" }, + }); + yield* service.compare({ + cwd: "/repo", + left: { kind: "branch", refName: "feature/left" }, + right: { kind: "working-tree" }, + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + ["diff", "--no-ext-diff", "--patch", "--minimal", "--reverse", "feature/right"], + ["diff", "--no-ext-diff", "--patch", "--minimal", "feature/left"], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("patch"); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts new file mode 100644 index 00000000000..78ba3f85933 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Diffs.test.ts @@ -0,0 +1,965 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +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, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : 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 GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +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("passes rebase refs after a positional separator", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.rebaseCurrentOnto({ + cwd: "/repo", + refName: "feature/source-control", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [["rebase", "--", "feature/source-control"]], + ); + }).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( + "publishes to the local branch name when the configured upstream is only a base ref", + () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.pushBranch({ + cwd: "/repo", + branchName: "split/vscode-extension-work", + }); + + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "split/vscode-extension-work@{upstream}", + ], + ["remote"], + [ + "push", + "-u", + "origin", + "split/vscode-extension-work:refs/heads/split/vscode-extension-work", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.branchUpstream": + return success("upstream/main\n"); + case "vcs.panel.pushBranch.remotes": + return success("origin\nupstream\n"); + default: + return 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("reads staged rename diffs against the original path", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: true }, + }); + + assert.equal(result.patch, "rename patch"); + assert.deepStrictEqual( + calls.map((call) => call.args), + [ + [ + "diff", + "--cached", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + "--", + "src/old.ts", + "src/new.ts", + ], + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("rename patch"); + }), + ), + ), + ); + }); + + it.effect("reads tracked stash file diffs against the stash base", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "stash", stashRef: "stash@{0}" }, + }); + + assert.equal(result.patch, "stash patch"); + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.readStashFileDiff", + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + "stash@{0}^1", + "stash@{0}", + "--", + "src/old.ts", + "src/new.ts", + ], + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return success("stash patch"); + }), + ), + ), + ); + }); + + it.effect("falls back to the stash untracked parent for untracked files", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/untracked.ts", + source: { kind: "stash", stashRef: "stash@{1}" }, + }); + + assert.equal(result.patch, "untracked stash patch"); + assert.deepStrictEqual( + calls.map((call) => ({ + operation: call.operation, + args: call.args, + allowNonZeroExit: call.allowNonZeroExit, + })), + [ + { + operation: "vcs.panel.readStashFileDiff", + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + "stash@{1}^1", + "stash@{1}", + "--", + "src/untracked.ts", + ], + allowNonZeroExit: false, + }, + { + operation: "vcs.panel.readStashUntrackedFileDiff", + args: [ + "show", + "--format=", + "--no-ext-diff", + "--patch", + "--minimal", + "stash@{1}^3", + "--", + "src/untracked.ts", + ], + allowNonZeroExit: true, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.readStashUntrackedFileDiff" + ? success("untracked stash patch") + : success(""); + }), + ), + ), + ); + }); + + it.effect("reads unstaged rename diffs with a temporary intent-to-add index", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: false }, + }); + + assert.equal(result.patch, "rename patch"); + assert.deepStrictEqual( + calls.map((call) => ({ operation: call.operation, args: call.args })), + [ + { + operation: "vcs.panel.readFileDiff.gitIndexPath", + args: ["rev-parse", "--git-path", "index"], + }, + { + operation: "vcs.panel.readFileDiff.tempIndexReadTree", + args: ["read-tree", "HEAD"], + }, + { + operation: "vcs.panel.readFileDiff.tempIndexIntentToAdd", + args: ["--literal-pathspecs", "add", "-N", "--", "src/new.ts"], + }, + { + operation: "vcs.panel.readFileDiff", + args: [ + "diff", + "--no-ext-diff", + "--patch", + "--minimal", + "--find-renames=20%", + "--", + "src/old.ts", + "src/new.ts", + ], + }, + ], + ); + const diffCall = calls.find((call) => call.operation === "vcs.panel.readFileDiff"); + assert.equal(Boolean(diffCall?.env?.GIT_INDEX_FILE?.length), true); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.readFileDiff.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + case "vcs.panel.readFileDiff": + return success("rename patch"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("does not render unstaged rename diff fallbacks as new untracked files", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.readFileDiff({ + cwd: "/repo", + path: "src/new.ts", + originalPath: "src/old.ts", + source: { kind: "working-tree", staged: false }, + }); + + assert.equal(result.patch, ""); + assert.isFalse(calls.some((call) => call.operation === "vcs.panel.readUntrackedFileDiff")); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.readFileDiff.gitIndexPath": + return success("/tmp/t3-code-test-missing-index"); + default: + return success(""); + } + }), + ), + ), + ); + }); + + it.effect("decodes quoted porcelain paths and keeps mixed unstaged rows in snapshots", () => + 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(snapshot.status.aheadOfDefaultCount, 4); + assert.deepStrictEqual(unstagedFiles, [ + { + path: "src/áudio.ts", + originalPath: null, + status: "untracked", + insertions: 0, + deletions: 0, + }, + { + path: "src/mixed.ts", + originalPath: null, + status: "modified", + insertions: 2, + 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", + "# branch.ab +2 -0", + "1 MM N... 100644 100644 100644 abc abc src/mixed.ts", + '? "src/\\303\\241udio.ts"', + ].join("\n"), + ); + case "vcs.panel.stagedNumstat": + return success("1\t0\tsrc/mixed.ts\0"); + case "vcs.panel.stagedNameStatus": + return success("M\0src/mixed.ts\0"); + case "vcs.panel.unstagedNumstat": + return success("2\t1\tsrc/mixed.ts\0"); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + aheadOfDefaultCount: 4, + }), + }, + ), + ), + ), + ); + + 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, + [ + "--literal-pathspecs", + "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("infers line-based numstat renames when name-status is missing", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual(result.commits[0]?.files, [ + { + path: "new-name.ts", + originalPath: "old-name.ts", + status: "renamed", + insertions: 3, + deletions: 1, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.branchCommitCount": + return success("1"); + case "vcs.panel.branchCommits": + return success( + "abc123\tabc123\tAda\tada@example.test\t2026-06-20T12:00:00.000Z\tRename file", + ); + case "vcs.panel.commitRefs": + case "vcs.panel.commitNameStatus": + return success(""); + case "vcs.panel.commitNumstat": + return success("3\t1\told-name.ts\tnew-name.ts\n"); + default: + return success(""); + } + }), + ), + ), + ), + ); + + it.effect("infers nul-delimited binary numstat renames when name-status is missing", () => + Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const result = yield* service.branchCommits({ + cwd: "/repo", + branch: branchRef, + baseRef: "main", + kind: "history", + skip: 0, + limit: 10, + }); + + assert.deepStrictEqual(result.commits[0]?.files, [ + { + path: "new\tname.bin", + originalPath: "old\tname.bin", + status: "renamed", + insertions: 0, + deletions: 0, + }, + ]); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.branchCommitCount": + return success("1"); + case "vcs.panel.branchCommits": + return success( + "abc123\tabc123\tAda\tada@example.test\t2026-06-20T12:00:00.000Z\tRename binary", + ); + case "vcs.panel.commitRefs": + case "vcs.panel.commitNameStatus": + return success(""); + case "vcs.panel.commitNumstat": + return success("-\t-\t\0old\tname.bin\0new\tname.bin\0"); + default: + return success(""); + } + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts new file mode 100644 index 00000000000..660f2bbd59d --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Refresh.test.ts @@ -0,0 +1,485 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +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, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : 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 GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +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("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"); + }), + ), + ), + ), + ); + + it.effect("refreshes only working-tree slices when repository status is unchanged", () => { + const calls: ExecuteGitInput[] = []; + let dirty = false; + let aheadCount = 0; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const initial = yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + assert.deepStrictEqual( + initial.localBranches.map((branch) => branch.name), + ["main"], + ); + calls.length = 0; + dirty = true; + + const incremental = yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + + assert.deepStrictEqual(incremental.localBranches, initial.localBranches); + assert.deepStrictEqual( + incremental.changeGroups.flatMap((group) => group.files.map((file) => file.path)), + ["changed.txt"], + ); + assert.deepStrictEqual(calls.map((call) => call.operation).toSorted(), [ + "vcs.panel.stagedNameStatus", + "vcs.panel.stagedNumstat", + "vcs.panel.statusPorcelain", + "vcs.panel.unstagedNumstat", + ]); + + calls.length = 0; + aheadCount = 1; + const fallback = yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + assert.equal(fallback.status.aheadCount, 1); + assert.isTrue(calls.some((call) => call.operation === "vcs.panel.localBranches")); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + calls.push(input); + switch (input.operation) { + case "vcs.panel.localBranches": + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + case "vcs.panel.statusPorcelain": + return success( + dirty + ? [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + `# branch.ab +${aheadCount} -0`, + "1 .M N... 100644 100644 100644 222222 333333 changed.txt", + ].join("\n") + : [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + `# branch.ab +${aheadCount} -0`, + ].join("\n"), + ); + case "vcs.panel.unstagedNumstat": + return success(dirty ? "1\t0\tchanged.txt\0" : ""); + case "vcs.panel.worktrees": + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.stagedNameStatus": + return success(""); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: dirty, + hasUpstream: true, + aheadCount, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("promotes working-tree refreshes while a full snapshot is in flight", () => { + let fullSnapshotStarted: Deferred.Deferred | null = null; + let releaseFullSnapshot: Deferred.Deferred | null = null; + let localBranchesCalls = 0; + let fresh = false; + return Effect.gen(function* () { + fullSnapshotStarted = yield* Deferred.make(); + releaseFullSnapshot = yield* Deferred.make(); + const service = yield* SourceControlPanelService; + + const initial = yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + assert.equal(initial.stashes[0]?.message, "old stash"); + fresh = true; + + const fullSnapshotFiber = yield* Effect.forkChild( + service.snapshot({ cwd: "/repo", refresh: "full" }), + ); + yield* Deferred.await(fullSnapshotStarted); + + const watcherSnapshot = yield* service.snapshot({ + cwd: "/repo", + refresh: "working-tree", + }); + assert.equal(watcherSnapshot.stashes[0]?.message, "fresh stash"); + assert.equal(localBranchesCalls, 3); + + yield* Deferred.succeed(releaseFullSnapshot, undefined); + yield* Fiber.join(fullSnapshotFiber); + + const cachedSnapshot = yield* service.snapshot({ + cwd: "/repo", + refresh: "working-tree", + }); + assert.equal(cachedSnapshot.stashes[0]?.message, "fresh stash"); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.gen(function* () { + switch (input.operation) { + case "vcs.panel.localBranches": { + localBranchesCalls += 1; + if (localBranchesCalls === 2) { + if (!fullSnapshotStarted || !releaseFullSnapshot) { + throw new Error("Expected snapshot gates to be initialized"); + } + yield* Deferred.succeed(fullSnapshotStarted, undefined); + yield* Deferred.await(releaseFullSnapshot); + } + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + } + case "vcs.panel.statusPorcelain": + return success( + [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + ].join("\n"), + ); + case "vcs.panel.stashes": + return success( + `stash@{0}\tabc123\t2026-07-20T10:00:00.000Z\t${ + fresh ? "fresh stash" : "old stash" + }`, + ); + default: + return success(""); + } + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("allows incremental refreshes after a full snapshot fails", () => { + let localBranchesCalls = 0; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.snapshot({ cwd: "/repo", refresh: "full" }); + + const failedFullExit = yield* Effect.exit( + service.snapshot({ cwd: "/repo", refresh: "full" }), + ); + assert.isTrue(Exit.isFailure(failedFullExit)); + + yield* service.snapshot({ cwd: "/repo", refresh: "working-tree" }); + assert.equal(localBranchesCalls, 2); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + if (input.operation === "vcs.panel.localBranches") { + localBranchesCalls += 1; + if (localBranchesCalls === 2) { + return failure("failed full snapshot"); + } + return success("main\t*\t/repo\t2026-07-20T10:00:00.000Z\torigin/main\t"); + } + if (input.operation === "vcs.panel.statusPorcelain") { + return success( + [ + "# branch.oid abc", + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +0 -0", + ].join("\n"), + ); + } + return success(""); + }), + { + status: () => + Effect.succeed({ + ...localStatus, + refName: "main", + isDefaultRef: true, + hasWorkingTreeChanges: false, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + pr: null, + }), + }, + ), + ), + ); + }); + + it.effect("deduplicates automatic fetch-all requests and preserves forced refreshes", () => { + const calls: ExecuteGitInput[] = []; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + yield* service.fetchAllRemotes({ cwd: "/repo" }); + yield* service.fetchAllRemotes({ cwd: "/repo-linked" }); + yield* service.fetchAllRemotes({ cwd: "/repo", force: true }); + + const fetchCalls = calls.filter((call) => call.operation === "vcs.panel.fetchAllRemotes"); + assert.equal(fetchCalls.length, 2); + assert.deepStrictEqual(fetchCalls[0]?.args, ["--git-dir", "/repo/.git", "fetch", "--all"]); + assert.equal(fetchCalls[0]?.cwd, "/repo"); + }).pipe( + Effect.provide( + makeTestLayer((input) => + Effect.sync(() => { + calls.push(input); + return input.operation === "vcs.panel.resolveGitCommonDir" + ? success("/repo/.git\n") + : success(); + }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts new file mode 100644 index 00000000000..de340d1f3d9 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.Snapshot.test.ts @@ -0,0 +1,811 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + SourceControlProviderError, + type ChangeRequest, + type SourceControlProviderKind, + type VcsRef, + type VcsStatusLocalResult, +} from "@t3tools/contracts"; + +import { + SourceControlPanelService, + layer as SourceControlPanelServiceLayer, +} from "./SourceControlPanelService.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { GitVcsDriver, type ExecuteGitInput, type ExecuteGitResult } from "../vcs/GitVcsDriver.ts"; + +const branchRef: VcsRef = { + name: "feature/source-control", + current: false, + isDefault: false, + worktreePath: null, +}; +const isGitCommandError = Schema.is(GitCommandError); + +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, +}); + +const emptyProvider = SourceControlProvider.SourceControlProvider.of({ + kind: "unknown", + listChangeRequests: () => Effect.succeed([]), + getChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getChangeRequest", + cwd: "/repo", + detail: "get change request not stubbed", + }), + ), + createChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createChangeRequest", + cwd: "/repo", + detail: "create change request not stubbed", + }), + ), + getRepositoryCloneUrls: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.getRepositoryCloneUrls", + cwd: "/repo", + detail: "repository clone URLs not stubbed", + }), + ), + getCommitAvatarUrl: () => Effect.succeed(null), + createRepository: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.createRepository", + cwd: "/repo", + detail: "create repository not stubbed", + }), + ), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => + Effect.fail( + new SourceControlProviderError({ + provider: "unknown", + operation: "test.checkoutChangeRequest", + cwd: "/repo", + detail: "checkout change request not stubbed", + }), + ), +}); + +function makeTestLayer( + execute: (input: ExecuteGitInput) => Effect.Effect, + workflow: Partial = {}, + providers: Partial< + Record + > = {}, + settings: Parameters[0] = {}, +) { + return SourceControlPanelServiceLayer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + Layer.provide( + Layer.succeed(GitWorkflowService, { + status: (input) => + workflow.status + ? workflow.status(input) + : workflow.localStatus + ? workflow.localStatus(input).pipe( + Effect.map((status) => ({ + ...status, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: + (status as { readonly aheadOfDefaultCount?: number }).aheadOfDefaultCount ?? + 0, + pr: null, + })), + ) + : 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 GitWorkflowService["Service"]), + ), + Layer.provide( + Layer.succeed(GitVcsDriver, { + execute, + } as unknown as GitVcsDriver["Service"]), + ), + Layer.provide( + Layer.succeed( + SourceControlProviderRegistry, + SourceControlProviderRegistry.of({ + get: (kind) => Effect.succeed(providers[kind] ?? emptyProvider), + resolveHandle: () => Effect.succeed({ provider: emptyProvider, context: null }), + resolve: () => Effect.succeed(emptyProvider), + discover: Effect.succeed([]), + }), + ), + ), + ); +} + +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("attaches worktree paths from git worktree porcelain output", () => { + const worktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: worktreePath }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath, + }, + { + name: "main", + current: false, + worktreePath, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t*\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${worktreePath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${worktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("drops missing branch worktree paths before building the panel snapshot", () => { + const missingWorktreePath = `${process.cwd()}/.missing-source-control-worktree-test`; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const service = yield* SourceControlPanelService; + + assert.isFalse(yield* fileSystem.exists(missingWorktreePath)); + + const snapshot = yield* service.snapshot({ cwd: process.cwd() }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath: null, + }, + { + name: "main", + current: false, + worktreePath: null, + }, + ], + ); + assert.deepStrictEqual(snapshot.worktreeChangeSets, []); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + "main\t\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t*\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${missingWorktreePath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${missingWorktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("includes dirty non-current worktrees as separate change sets", () => { + const rootPath = process.cwd(); + const worktreePath = `${process.cwd()}/..`; + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: rootPath }); + + assert.deepStrictEqual( + snapshot.worktreeChangeSets.map((changeSet) => ({ + branchName: changeSet.branchName, + worktreePath: changeSet.worktreePath, + files: changeSet.changeGroups.flatMap((group) => + group.files.map((file) => ({ + group: group.kind, + path: file.path, + status: file.status, + insertions: file.insertions, + deletions: file.deletions, + })), + ), + })), + [ + { + branchName: "feature/source-control", + worktreePath, + files: [ + { + group: "staged", + path: "src/staged.ts", + status: "added", + insertions: 2, + deletions: 0, + }, + { + group: "unstaged", + path: "src/unstaged.ts", + status: "modified", + insertions: 3, + deletions: 1, + }, + ], + }, + ], + ); + assert.equal(snapshot.changeGroups.flatMap((group) => group.files).length, 0); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + return success( + [ + `main\t*\t${rootPath}\t2026-06-20T12:00:00.000Z\torigin/main\t`, + `feature/source-control\t\t${worktreePath}\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t`, + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success( + [ + `worktree ${rootPath}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${worktreePath}`, + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ); + case "vcs.panel.statusPorcelain": + if (input.cwd === worktreePath) { + return success( + [ + "# branch.oid def", + "# branch.head feature/source-control", + "1 A. N... 000000 100644 100644 000000 111111 src/staged.ts", + "1 .M N... 100644 100644 100644 222222 333333 src/unstaged.ts", + ].join("\n"), + ); + } + return success("# branch.oid abc\n# branch.head main"); + case "vcs.panel.stagedNumstat": + return input.cwd === worktreePath + ? success("2\t0\tsrc/staged.ts\0") + : success(""); + case "vcs.panel.stagedNameStatus": + return input.cwd === worktreePath ? success("A\0src/staged.ts\0") : success(""); + case "vcs.panel.unstagedNumstat": + return input.cwd === worktreePath + ? success("3\t1\tsrc/unstaged.ts\0") + : success(""); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "main", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("falls back to branch-format worktree paths when worktree porcelain is empty", () => { + const rootPath = process.cwd(); + const worktreePath = process.cwd(); + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: worktreePath }); + + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath, + }, + { + name: "main", + current: false, + worktreePath: rootPath, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + assert.ok(input.args.join(" ").includes("%(worktreepath)")); + return success( + [ + `main\t\t${rootPath}\t2026-06-20T12:00:00.000Z\torigin/main\t`, + `feature/source-control\t*\t${worktreePath}\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]`, + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success(""); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("falls back when git branch does not support worktreepath formatting", () => { + let localBranchesCalls = 0; + + return Effect.gen(function* () { + const service = yield* SourceControlPanelService; + + const snapshot = yield* service.snapshot({ cwd: "/repo" }); + + assert.equal(localBranchesCalls, 2); + assert.deepStrictEqual( + snapshot.localBranches.map((branch) => ({ + name: branch.name, + current: branch.current, + worktreePath: branch.worktreePath, + lastActivityAt: branch.lastActivityAt, + upstreamName: branch.upstreamName, + aheadCount: branch.aheadCount, + behindCount: branch.behindCount, + })), + [ + { + name: "feature/source-control", + current: true, + worktreePath: null, + lastActivityAt: "2026-06-21T12:00:00.000Z", + upstreamName: "origin/feature/source-control", + aheadCount: 1, + behindCount: 0, + }, + { + name: "main", + current: false, + worktreePath: null, + lastActivityAt: "2026-06-20T12:00:00.000Z", + upstreamName: "origin/main", + aheadCount: 0, + behindCount: 0, + }, + ], + ); + }).pipe( + Effect.provide( + makeTestLayer( + (input) => + Effect.sync(() => { + switch (input.operation) { + case "vcs.panel.localBranches": + localBranchesCalls += 1; + if (localBranchesCalls === 1) { + assert.ok(input.args.join(" ").includes("%(worktreepath)")); + assert.equal(input.allowNonZeroExit, true); + return failure("fatal: unknown field name: worktreepath"); + } + assert.ok(!input.args.join(" ").includes("%(worktreepath)")); + assert.ok(input.args.join(" ").includes("%09%09")); + return success( + [ + "main\t\t\t2026-06-20T12:00:00.000Z\torigin/main\t", + "feature/source-control\t*\t\t2026-06-21T12:00:00.000Z\torigin/feature/source-control\t[ahead 1]", + ].join("\n"), + ); + case "vcs.panel.worktrees": + return success(""); + case "vcs.panel.statusPorcelain": + return success("# branch.oid abc\n# branch.head feature/source-control"); + case "vcs.panel.remotes": + case "vcs.panel.stashes": + case "vcs.panel.stagedNumstat": + case "vcs.panel.unstagedNumstat": + return success(""); + default: + return success(""); + } + }), + { + localStatus: () => + Effect.succeed({ + ...localStatus, + refName: "feature/source-control", + hasWorkingTreeChanges: false, + }), + }, + ), + ), + ); + }); + + it.effect("keeps git-derived actionable forks when provider change request listing fails", () => + 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@github.com:fork/repo.git\t(fetch)", + "origin\tgit@github.com:fork/repo.git\t(push)", + "upstream\tgit@github.com:upstream/repo.git\t(fetch)", + "upstream\tgit@github.com: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, + }), + }, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + listChangeRequests: () => + Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "test.listChangeRequests", + cwd: "/repo", + detail: "provider unavailable", + }), + ), + }), + }, + ), + ), + ), + ); + + it.effect("surfaces open pull request base branches 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: "origin", + remoteBranchName: "main", + remoteRefName: "origin/main", + aheadCount: 0, + behindCount: 2, + lastActivityAt: "2026-06-17T11: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\t\t", + "fresh\t\t\t2026-06-17T09:00:00.000Z\t\t", + ].join("\n"), + ); + case "vcs.panel.remotes": + return success( + [ + "origin\tgit@github.com:acme/repo.git\t(fetch)", + "origin\tgit@github.com:acme/repo.git\t(push)", + ].join("\n"), + ); + case "vcs.panel.remoteBranches": + return success( + [ + "origin/main\t2026-06-17T11:00:00.000Z", + "origin/develop\t2026-06-17T08:00:00.000Z", + ].join("\n"), + ); + case "vcs.panel.branchForkMergeBase": + return success("abc123\n"); + case "vcs.panel.branchForkAheadBehind": + return input.args.includes("feature...origin/main") + ? success("0\t2\n") + : success("1\t0\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, + }), + }, + { + github: SourceControlProvider.SourceControlProvider.of({ + ...emptyProvider, + kind: "github", + listChangeRequests: (input) => { + const byHead: Record = { + feature: [ + { + provider: "github", + number: 42, + title: "Feature", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature", + state: "open", + updatedAt: Option.none(), + }, + ], + fresh: [ + { + provider: "github", + number: 43, + title: "Fresh", + url: "https://github.com/acme/repo/pull/43", + baseRefName: "develop", + headRefName: "fresh", + state: "open", + updatedAt: Option.none(), + }, + ], + }; + return Effect.succeed(byHead[input.headSelector] ?? []); + }, + }), + }, + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlPanelService.test.ts b/apps/server/src/sourceControl/SourceControlPanelService.test.ts new file mode 100644 index 00000000000..22c9506d0f4 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.test.ts @@ -0,0 +1,5 @@ +import "./SourceControlPanelService.Core.test.ts"; +import "./SourceControlPanelService.Diffs.test.ts"; +import "./SourceControlPanelService.Branches.test.ts"; +import "./SourceControlPanelService.Snapshot.test.ts"; +import "./SourceControlPanelService.Refresh.test.ts"; diff --git a/apps/server/src/sourceControl/SourceControlPanelService.ts b/apps/server/src/sourceControl/SourceControlPanelService.ts new file mode 100644 index 00000000000..de4e5cf6b00 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelService.ts @@ -0,0 +1,896 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { + GitCommandError, + type VcsPanelAddRemoteInput, + type VcsPanelBranchActionInput, + type VcsPanelBranchCommitsInput, + type VcsPanelBranchCommitsResult, + type VcsPanelBranchDetails, + type VcsPanelBranchDetailsInput, + type VcsPanelCommitActionInput, + type VcsPanelCommitInput, + type VcsPanelCompareInput, + type VcsPanelCompareResult, + type VcsPanelDeleteBranchInput, + type VcsPanelFileActionInput, + type VcsPanelFileChange, + type VcsPanelFileDiffInput, + type VcsPanelFileDiffResult, + type VcsPanelFetchAllRemotesInput, + type VcsPanelRemote, + type VcsPanelRemoteInput, + type VcsPanelRefActionInput, + type VcsPanelSnapshotInput, + type VcsPanelSnapshotResult, + type VcsPanelStashDetails, + type VcsPanelStashDetailsInput, + type VcsPanelStashInput, + type VcsPanelUndoCommitInput, + type VcsPanelWorktreeChangeSet, + type VcsPanelWorkingTreeFileEnrichmentInput, + type VcsPanelWorkingTreeFileEnrichmentResult, + type SourceControlProviderKind, + type VcsPullResult, + type VcsRef, + type VcsStatusResult, +} from "@t3tools/contracts"; + +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { GitVcsDriver, type ExecuteGitProgress } from "../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import { makeSourceControlPanelActions } from "./SourceControlPanelActions.ts"; +import { makeSourceControlPanelReaders } from "./SourceControlPanelReaders.ts"; +import { + mergeNumstats, + panelStatusFromLocal, + parseNumstat, + readNulField, + untrackedPathsFromPorcelain, + unstagedFilesFromPorcelainStatus, +} from "./SourceControlPanelStatusParsers.ts"; +const isGitCommandError = Schema.is(GitCommandError); +const LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS = [ + "branch", + "--format=%(refname:short)%09%(HEAD)%09%(worktreepath)%09%(committerdate:iso-strict)%09%(upstream:short)%09%(upstream:track)", +] as const; +const LOCAL_BRANCHES_WITHOUT_WORKTREE_PATH_ARGS = [ + "branch", + "--format=%(refname:short)%09%(HEAD)%09%09%(committerdate:iso-strict)%09%(upstream:short)%09%(upstream:track)", +] as const; + +type ConfiguredSourceControlProviderKind = Exclude; + +function isConfiguredSourceControlProviderKind( + kind: SourceControlProviderKind, +): kind is ConfiguredSourceControlProviderKind { + return kind !== "unknown"; +} + +interface PanelSnapshotCacheState { + readonly latestRequestByCwd: ReadonlyMap; + readonly latestFullRequestByCwd: ReadonlyMap; + readonly completedFullRequestByCwd: ReadonlyMap; + readonly snapshotsByCwd: ReadonlyMap; +} + +const PANEL_SNAPSHOT_CACHE_CAPACITY = 64; +const COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL = + "The Git pre-commit hook could not load a required native dependency. Reinstall the repository dependencies and try again."; +const COMMIT_HOOK_FAILURE_DETAIL = + "The Git pre-commit hook failed. Run the repository pre-commit hook in a terminal for details."; + +type CommitFailureHint = "hook-failed" | "native-dependency"; + +function commitFailureHintFromOutputLine(line: string): CommitFailureHint | null { + if ( + line.includes("Cannot find native binding") || + line.includes("Cannot find module 'vite-plus/binding'") || + line.includes('Cannot find module "vite-plus/binding"') + ) { + return "native-dependency"; + } + if (line.includes("VITE+ - pre-commit script failed")) { + return "hook-failed"; + } + return null; +} + +function commitFailureDetail(hint: CommitFailureHint | null): string | null { + switch (hint) { + case "native-dependency": + return COMMIT_HOOK_NATIVE_DEPENDENCY_FAILURE_DETAIL; + case "hook-failed": + return COMMIT_HOOK_FAILURE_DETAIL; + case null: + return null; + } +} + +function setBoundedMapEntry( + source: ReadonlyMap, + key: K, + value: V, + capacity: number, +): ReadonlyMap { + const next = new Map(source); + next.delete(key); + next.set(key, value); + while (next.size > capacity) { + const oldestKey = next.keys().next().value; + if (oldestKey === undefined) break; + next.delete(oldestKey); + } + return next; +} + +export class SourceControlPanelService extends Context.Service< + SourceControlPanelService, + { + 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: VcsPanelFetchAllRemotesInput, + ) => 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; + } +>()("t3/sourceControl/SourceControlPanelService") {} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function gitError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +) { + return new GitCommandError({ + operation, + command: commandLabel(args), + cwd, + detail, + ...(cause === undefined ? {} : { cause: sanitizeErrorCause(cause) }), + }); +} + +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), cause); +} + +function isUnsupportedWorktreePathFormat(detail: string) { + detail = detail.toLowerCase(); + return detail.includes("worktreepath") && detail.includes("unknown field"); +} + +import { + parseCommits, + parseFileChangesFromNumstat, + parseLocalBranches, + parseNameStatus, + parseRemoteBranches, + parseRemoteVerbose, + parseStashes, + parseWorktreeBranchEntries, + parseWorktreeBranchPaths, + uniquePaths, + type WorktreeBranchEntry, +} from "./SourceControlPanelParsers.ts"; + +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 sourceControlProviders = Option.getOrUndefined( + Context.getOption(context, SourceControlProviderRegistry), + ); + const textGeneration = Option.getOrUndefined(Context.getOption(context, TextGeneration)); + + const runResult = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, + ) => + git + .execute({ + operation, + cwd, + args, + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.progress !== undefined ? { progress: options.progress } : {}), + allowNonZeroExit: options?.allowNonZeroExit ?? false, + timeoutMs: 30_000, + maxOutputBytes: 8 * 1024 * 1024, + appendTruncationMarker: true, + }) + .pipe(Effect.mapError(asGitCommandError(operation, cwd, args))); + + const run = ( + operation: string, + cwd: string, + args: readonly string[], + options?: { + readonly allowNonZeroExit?: boolean; + readonly env?: NodeJS.ProcessEnv; + readonly progress?: ExecuteGitProgress; + }, + ) => + runResult(operation, cwd, args, options).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()), + ); + }), + ); + + const snapshotCacheRef = yield* Ref.make({ + latestRequestByCwd: new Map(), + latestFullRequestByCwd: new Map(), + completedFullRequestByCwd: new Map(), + snapshotsByCwd: new Map(), + }); + + const fetchAllRemotesCache = yield* Cache.makeWith( + (gitCommonDir: string) => { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return run("vcs.panel.fetchAllRemotes", fetchCwd, [ + "--git-dir", + gitCommonDir, + "fetch", + "--all", + ]).pipe(Effect.asVoid); + }, + { + capacity: 128, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(5) : Duration.seconds(5)), + }, + ); + + const resolveGitCommonDir = Effect.fn("SourceControlPanelService.resolveGitCommonDir")(function* ( + cwd: string, + ) { + const commonDir = (yield* run("vcs.panel.resolveGitCommonDir", cwd, [ + "rev-parse", + "--git-common-dir", + ])).trim(); + return path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir); + }); + + const fetchAllRemotes: SourceControlPanelService["Service"]["fetchAllRemotes"] = Effect.fn( + "fetchAllRemotes", + )(function* (input) { + const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + if (input.force === true) { + yield* Cache.invalidate(fetchAllRemotesCache, gitCommonDir); + } + yield* Cache.get(fetchAllRemotesCache, gitCommonDir); + }); + + const withTemporaryIntentToAddIndex = ( + input: { + readonly cwd: string; + readonly paths: readonly string[]; + readonly operations: { + readonly gitIndexPath: string; + readonly tempIndexReadTree: string; + readonly tempIndexIntentToAdd: string; + }; + }, + body: (env: NodeJS.ProcessEnv) => Effect.Effect, + ) => + Effect.gen(function* () { + const gitIndexPath = (yield* run(input.operations.gitIndexPath, input.cwd, [ + "rev-parse", + "--git-path", + "index", + ])).trim(); + const sourceIndexPath = path.isAbsolute(gitIndexPath) + ? gitIndexPath + : path.resolve(input.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(input.operations.tempIndexReadTree, input.cwd, ["read-tree", "HEAD"], { + env, + }).pipe( + Effect.asVoid, + Effect.catch(() => Effect.void), + ), + ), + ); + yield* run( + input.operations.tempIndexIntentToAdd, + input.cwd, + ["--literal-pathspecs", "add", "-N", "--", ...input.paths], + { env }, + ).pipe(Effect.asVoid); + return yield* body(env); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }); + + const withTemporarySelectedIndex = ( + cwd: string, + paths: readonly string[], + body: (env: NodeJS.ProcessEnv) => Effect.Effect, + ) => + Effect.gen(function* () { + const tempDir = yield* fileSystem + .makeTempDirectory({ prefix: "t3-vcs-selected-index-" }) + .pipe( + Effect.mapError(asGitCommandError("vcs.panel.commitStaged.tempIndex", cwd, ["commit"])), + ); + return yield* Effect.gen(function* () { + const env = { + ...globalThis.process.env, + GIT_INDEX_FILE: path.join(tempDir, "index"), + }; + const headResult = yield* runResult( + "vcs.panel.commitStaged.tempIndexResolveHead", + cwd, + ["rev-parse", "--verify", "HEAD"], + { allowNonZeroExit: true, env }, + ); + yield* run( + "vcs.panel.commitStaged.tempIndexReadTree", + cwd, + headResult.exitCode === 0 ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], + { env }, + ).pipe(Effect.asVoid); + yield* run( + "vcs.panel.commitStaged.tempIndexAddSelected", + cwd, + ["--literal-pathspecs", "add", "-A", "--", ...paths], + { env }, + ).pipe(Effect.asVoid); + return yield* body(env); + }).pipe( + Effect.ensuring( + fileSystem.remove(tempDir, { recursive: true, force: true }).pipe(Effect.ignore), + ), + ); + }); + + const branchWithExistingWorktreePath = (branch: VcsRef) => { + if (!branch.worktreePath) return Effect.succeed(branch); + return fileSystem.exists(branch.worktreePath).pipe( + Effect.map((exists) => (exists ? branch : { ...branch, worktreePath: null })), + Effect.orElseSucceed(() => ({ ...branch, worktreePath: null })), + ); + }; + + const { + actionableForkBranches, + branchCommits, + branchDetails, + changeGroupsHaveFiles, + generatedCommitMessage, + generatedStashMessage, + readWorkingTreeChangeGroups, + refExists, + stashDetails, + upstreamForRef, + } = makeSourceControlPanelReaders({ + run, + serverSettings, + sourceControlProviders, + textGeneration, + }); + const unstagedFilesWithUntrackedRenames = (cwd: string, untrackedPaths: readonly string[]) => + Effect.gen(function* () { + if (untrackedPaths.length === 0) return null; + + return yield* withTemporaryIntentToAddIndex( + { + cwd, + paths: untrackedPaths, + operations: { + gitIndexPath: "vcs.panel.gitIndexPath", + tempIndexReadTree: "vcs.panel.tempIndexReadTree", + tempIndexIntentToAdd: "vcs.panel.tempIndexIntentToAdd", + }, + }, + (env) => + Effect.gen(function* () { + 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.orElseSucceed(() => null)); + + const enrichWorkingTreeFiles: SourceControlPanelService["Service"]["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 readWorktreeChangeSets = Effect.fn("readWorktreeChangeSets")(function* ( + cwd: string, + localBranches: ReadonlyArray, + worktreeBranchEntries: ReadonlyArray | null, + ) { + return yield* Effect.forEach( + localBranches.filter((branch) => { + if (branch.current || !branch.worktreePath) return false; + if (path.resolve(branch.worktreePath) === path.resolve(cwd)) return false; + return ( + worktreeBranchEntries === null || + worktreeBranchEntries.some( + (entry) => + entry.branchName === branch.name && entry.worktreePath === branch.worktreePath, + ) + ); + }), + (branch) => + readWorkingTreeChangeGroups(branch.worktreePath!).pipe( + Effect.map((result): VcsPanelWorktreeChangeSet | null => + changeGroupsHaveFiles(result.changeGroups) + ? { + branchName: branch.name, + worktreePath: branch.worktreePath!, + current: false, + lastActivityAt: branch.lastActivityAt ?? null, + changeGroups: result.changeGroups, + } + : null, + ), + Effect.orElseSucceed(() => null), + ), + { concurrency: 4 }, + ).pipe( + Effect.map((sets) => + sets + .filter((set): set is VcsPanelWorktreeChangeSet => set !== null) + .toSorted((left, right) => { + const leftTime = Date.parse(left.lastActivityAt ?? ""); + const rightTime = Date.parse(right.lastActivityAt ?? ""); + const activity = + (Number.isFinite(rightTime) ? rightTime : 0) - + (Number.isFinite(leftTime) ? leftTime : 0); + return activity !== 0 ? activity : left.branchName.localeCompare(right.branchName); + }), + ), + ); + }); + + const readFullSnapshot = Effect.fn("readFullSnapshot")(function* (cwd: string) { + const [ + localStatus, + localBranchesOutput, + worktreeListOutput, + workingTree, + remotesOutput, + stashes, + ] = yield* Effect.all( + [ + workflow + .status({ cwd }) + .pipe(Effect.mapError(asGitCommandError("vcs.panel.status", cwd, ["status"]))), + runResult("vcs.panel.localBranches", cwd, LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS, { + allowNonZeroExit: true, + }).pipe( + Effect.flatMap((result) => { + if (result.exitCode === 0) return Effect.succeed(result.stdout); + const detail = result.stderr.trim() || result.stdout.trim(); + return isUnsupportedWorktreePathFormat(detail) + ? run("vcs.panel.localBranches", cwd, LOCAL_BRANCHES_WITHOUT_WORKTREE_PATH_ARGS) + : Effect.fail( + gitError( + "vcs.panel.localBranches", + cwd, + LOCAL_BRANCHES_WITH_WORKTREE_PATH_ARGS, + detail, + ), + ); + }), + ), + run("vcs.panel.worktrees", cwd, ["worktree", "list", "--porcelain"], { + allowNonZeroExit: true, + }), + readWorkingTreeChangeGroups(cwd), + run("vcs.panel.remotes", cwd, ["remote", "-v"]), + run("vcs.panel.stashes", cwd, ["stash", "list", "--format=%gd%x09%H%x09%cI%x09%gs"]), + ], + { concurrency: "unbounded" }, + ); + + const localBranches = yield* Effect.forEach( + parseLocalBranches( + localBranchesOutput, + parseWorktreeBranchPaths(worktreeListOutput), + localStatus.isDefaultRef ? localStatus.refName : null, + ), + branchWithExistingWorktreePath, + { concurrency: "unbounded" }, + ); + const remotes = parseRemoteVerbose(remotesOutput); + const remotesWithBranches = yield* Effect.forEach( + remotes, + (remote) => + run("vcs.panel.remoteBranches", 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)?.name ?? + localBranches.find((ref) => !ref.current)?.name ?? + null; + const forkBranches = yield* actionableForkBranches(cwd, localBranches, remotesWithBranches); + const worktreeBranchEntries = parseWorktreeBranchEntries(worktreeListOutput); + const worktreeChangeSets = yield* readWorktreeChangeSets( + cwd, + localBranches, + worktreeBranchEntries, + ); + return { + status: panelStatusFromLocal(localStatus, workingTree.porcelain), + changeGroups: workingTree.changeGroups, + worktreeChangeSets, + localBranches, + branchDetails: [], + remotes: remotesWithBranches, + actionableForkBranches: forkBranches, + stashes: parseStashes(stashes), + recentCommits: [], + defaultCompareRef, + }; + }); + + const readWorkingTreeSnapshot = Effect.fn("readWorkingTreeSnapshot")(function* ( + cwd: string, + cached: VcsPanelSnapshotResult, + ) { + const [localStatus, workingTree] = yield* Effect.all( + [ + workflow + .status({ cwd }) + .pipe(Effect.mapError(asGitCommandError("vcs.panel.status", cwd, ["status"]))), + readWorkingTreeChangeGroups(cwd), + ], + { concurrency: "unbounded" }, + ); + const status = panelStatusFromLocal(localStatus, workingTree.porcelain); + const worktreeChangeSets = yield* readWorktreeChangeSets(cwd, cached.localBranches, null); + return { status, workingTree, worktreeChangeSets }; + }); + + const repositoryStatusChanged = (left: VcsStatusResult, right: VcsStatusResult): boolean => + left.isRepo !== right.isRepo || + left.hasPrimaryRemote !== right.hasPrimaryRemote || + left.isDefaultRef !== right.isDefaultRef || + left.refName !== right.refName || + left.hasUpstream !== right.hasUpstream || + left.aheadCount !== right.aheadCount || + left.behindCount !== right.behindCount || + left.aheadOfDefaultCount !== right.aheadOfDefaultCount || + JSON.stringify(left.sourceControlProvider ?? null) !== + JSON.stringify(right.sourceControlProvider ?? null) || + JSON.stringify(left.pr) !== JSON.stringify(right.pr); + + const snapshot: SourceControlPanelService["Service"]["snapshot"] = Effect.fn("snapshot")( + function* (input) { + const cacheKey = path.resolve(input.cwd); + const request = yield* Ref.modify(snapshotCacheRef, (state) => { + const requestId = (state.latestRequestByCwd.get(cacheKey) ?? 0) + 1; + const cached = state.snapshotsByCwd.get(cacheKey) ?? null; + const latestFullRequest = state.latestFullRequestByCwd.get(cacheKey) ?? 0; + const completedFullRequest = state.completedFullRequestByCwd.get(cacheKey) ?? 0; + const full = + input.refresh !== "working-tree" || + cached === null || + latestFullRequest > completedFullRequest; + const latestRequestByCwd = setBoundedMapEntry( + state.latestRequestByCwd, + cacheKey, + requestId, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ); + const latestFullRequestByCwd = full + ? setBoundedMapEntry( + state.latestFullRequestByCwd, + cacheKey, + requestId, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ) + : state.latestFullRequestByCwd; + return [ + { + requestId, + cached, + full, + }, + { ...state, latestRequestByCwd, latestFullRequestByCwd }, + ] as const; + }); + + let nextSnapshot: VcsPanelSnapshotResult; + if (!request.full && request.cached !== null) { + const incremental = yield* readWorkingTreeSnapshot(input.cwd, request.cached); + nextSnapshot = repositoryStatusChanged(request.cached.status, incremental.status) + ? yield* readFullSnapshot(input.cwd) + : { + ...request.cached, + status: incremental.status, + changeGroups: incremental.workingTree.changeGroups, + worktreeChangeSets: incremental.worktreeChangeSets, + }; + } else { + nextSnapshot = yield* readFullSnapshot(input.cwd).pipe( + Effect.ensuring( + Ref.update(snapshotCacheRef, (state) => ({ + ...state, + completedFullRequestByCwd: setBoundedMapEntry( + state.completedFullRequestByCwd, + cacheKey, + Math.max(state.completedFullRequestByCwd.get(cacheKey) ?? 0, request.requestId), + PANEL_SNAPSHOT_CACHE_CAPACITY, + ), + })), + ), + ); + } + + yield* Ref.update(snapshotCacheRef, (state) => { + if (state.latestRequestByCwd.get(cacheKey) !== request.requestId) { + return state; + } + const snapshotsByCwd = setBoundedMapEntry( + state.snapshotsByCwd, + cacheKey, + nextSnapshot, + PANEL_SNAPSHOT_CACHE_CAPACITY, + ); + return { ...state, snapshotsByCwd }; + }); + return nextSnapshot; + }, + ); + + const actions = makeSourceControlPanelActions({ + generatedCommitMessage, + generatedStashMessage, + refExists, + run, + snapshot, + upstreamForRef, + withTemporaryIntentToAddIndex, + withTemporarySelectedIndex, + workflow, + }); + + 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), + enrichWorkingTreeFiles, + fetchAllRemotes, + ...actions, + }); +}); + +export const layer = Layer.effect(SourceControlPanelService, make()); diff --git a/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts b/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts new file mode 100644 index 00000000000..bed9496ccc7 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlPanelStatusParsers.ts @@ -0,0 +1,318 @@ +import type { + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelFileStatus, + VcsPanelSnapshotResult, + VcsStatusLocalResult, + VcsStatusResult, +} from "@t3tools/contracts"; + +export function parseCount(value: string | undefined): number { + const parsed = Number.parseInt(value ?? "0", 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +export 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 }; +} + +export 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; +} + +export 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; +} + +export 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 decodeGitQuotedPath(path: string): string { + if (!path.startsWith('"') || !path.endsWith('"')) return path; + const bytes: number[] = []; + const inner = path.slice(1, -1); + for (let index = 0; index < inner.length; index += 1) { + const char = inner[index] ?? ""; + if (char !== "\\") { + bytes.push(...Buffer.from(char)); + continue; + } + const next = inner[index + 1]; + if (next === undefined) { + bytes.push("\\".charCodeAt(0)); + continue; + } + const octal = /^[0-7]{1,3}/u.exec(inner.slice(index + 1))?.[0]; + if (octal) { + bytes.push(Number.parseInt(octal, 8)); + index += octal.length; + continue; + } + index += 1; + switch (next) { + case "a": + bytes.push(0x07); + break; + case "b": + bytes.push(0x08); + break; + case "f": + bytes.push(0x0c); + break; + case "n": + bytes.push(0x0a); + break; + case "r": + bytes.push(0x0d); + break; + case "t": + bytes.push(0x09); + break; + case "v": + bytes.push(0x0b); + break; + default: + bytes.push(...Buffer.from(next)); + break; + } + } + return Buffer.from(bytes).toString("utf8"); +} + +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, + }); +} + +export 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 = decodeGitQuotedPath(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 = decodeGitQuotedPath(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 [rawPath = "", rawOriginalPath = null] = pathPart.split("\t"); + const path = decodeGitQuotedPath(rawPath); + const originalPath = rawOriginalPath === null ? null : decodeGitQuotedPath(rawOriginalPath); + 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 !== "." && input.stagedFiles === undefined) { + addChange(staged, { + path, + originalPath, + status: statusFromCode(stagedCode, "modified"), + stats: input.stagedStats.get(path), + }); + } + if (unstagedCode !== "." && input.unstagedFiles === undefined) { + 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) }, + ]; +} + +export function untrackedPathsFromPorcelain(status: string): string[] { + return status.split(/\r?\n/u).flatMap((line) => (line.startsWith("? ") ? [line.slice(2)] : [])); +} + +export function unstagedFilesFromPorcelainStatus(input: { + status: string; + unstagedStats?: Map; + untrackedStats?: Map; +}): readonly VcsPanelFileChange[] { + return ( + parsePorcelainStatus({ + status: input.status, + 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 }; +} + +export function panelStatusFromLocal( + local: VcsStatusLocalResult | VcsStatusResult, + porcelain: string, +): VcsPanelSnapshotResult["status"] { + const sync = parsePorcelainBranchSync(porcelain); + return { + ...local, + ...sync, + aheadOfDefaultCount: "aheadOfDefaultCount" in local ? local.aheadOfDefaultCount : 0, + pr: null, + }; +} diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa42..406e7c9eb19 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -3,12 +3,12 @@ import * as Effect from "effect/Effect"; import type { ChangeRequest, ChangeRequestState, - SourceControlProviderError, SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, SourceControlRepositoryVisibility, } from "@t3tools/contracts"; +import { SourceControlProviderError } from "@t3tools/contracts"; export interface SourceControlProviderContext { readonly provider: SourceControlProviderInfo; @@ -23,6 +23,8 @@ export interface SourceControlRefSelector { } const MAX_ERROR_TRANSPORT_VALUE_LENGTH = 256; +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'<>`]+/giu; +const EMBEDDED_HTTP_AUTHORITY_CREDENTIALS_PATTERN = /^(https?:\/\/)[^/?#@]*@/iu; /** * Sanitizes user-provided source-control identifiers before attaching them to @@ -35,21 +37,35 @@ export function transportSafeSourceControlErrorValue(value: string): string { const codePoint = character.codePointAt(0); printable += codePoint !== undefined && (codePoint < 32 || codePoint === 127) ? " " : character; } - const normalized = printable.trim().replace(/\s+/gu, " "); + const normalized = printable + .replace(EMBEDDED_HTTP_URL_PATTERN, transportSafeEmbeddedUrl) + .trim() + .replace(/\s+/gu, " "); let safe = normalized; + const parsedUrl = transportSafeUrl(normalized); + safe = parsedUrl ?? normalized; + + return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); +} + +function transportSafeEmbeddedUrl(value: string): string { + return ( + transportSafeUrl(value) ?? value.replace(EMBEDDED_HTTP_AUTHORITY_CREDENTIALS_PATTERN, "$1") + ); +} + +function transportSafeUrl(value: string): string | null { try { - const url = new URL(normalized); + const url = new URL(value); url.username = ""; url.password = ""; url.search = ""; url.hash = ""; - safe = url.toString(); + return url.toString(); } catch { - // Plain repository and change-request identifiers are not URLs. + return null; } - - return safe.slice(0, MAX_ERROR_TRANSPORT_VALUE_LENGTH); } export function parseSourceControlOwnerRef( @@ -79,6 +95,95 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } +export interface SourceControlProviderCommandError { + readonly command?: string; + readonly detail?: string; + readonly message?: string; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (typeof value !== "object" || value === null || !(key in value)) { + return undefined; + } + const field = (value as Record)[key]; + return typeof field === "string" ? field : undefined; +} + +function normalizedSourceControlProviderCause(error: unknown) { + if (typeof error !== "object" || error === null) { + return { + message: transportSafeSourceControlErrorValue(String(error)), + }; + } + + return { + ...safeErrorField(error, "_tag"), + ...safeErrorField(error, "name"), + ...safeErrorField(error, "command"), + ...safeErrorField(error, "operation"), + ...safeErrorField(error, "reference"), + ...safeErrorField(error, "repository"), + ...safeErrorField(error, "detail"), + ...safeErrorField(error, "message"), + }; +} + +function safeErrorField(error: unknown, key: string): Record { + const value = readStringField(error, key); + return value === undefined ? {} : { [key]: transportSafeSourceControlErrorValue(value) }; +} + +export function sourceControlProviderError(input: { + readonly provider: SourceControlProviderKind; + readonly operation: string; + readonly cwd: string; + readonly error: unknown; + readonly detail?: string; + readonly reference?: string; + readonly repository?: string; +}): SourceControlProviderError { + const command = readStringField(input.error, "command"); + const detail = + readStringField(input.error, "detail") ?? + input.detail ?? + readStringField(input.error, "message") ?? + "Source control provider operation failed."; + + return new SourceControlProviderError({ + provider: input.provider, + operation: input.operation, + ...(command !== undefined ? { command: transportSafeSourceControlErrorValue(command) } : {}), + cwd: input.cwd, + ...(input.reference !== undefined + ? { reference: transportSafeSourceControlErrorValue(input.reference) } + : {}), + ...(input.repository !== undefined + ? { repository: transportSafeSourceControlErrorValue(input.repository) } + : {}), + detail: transportSafeSourceControlErrorValue(detail), + cause: normalizedSourceControlProviderCause(input.error), + }); +} + +export function repositoryPathFromRemoteUrl(remoteUrl: string): string | null { + const withoutSuffix = (value: string) => value.replace(/\.git$/u, ""); + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) return null; + + try { + const url = new URL(trimmed); + const path = withoutSuffix(decodeURIComponent(url.pathname).replace(/^\/+/u, "").trim()); + return path.length > 0 ? path : null; + } catch { + const scpLike = /^(?:[^@/\s]+@)?[^:/\s]+:(.+)$/u.exec(trimmed); + if (scpLike?.[1]) { + const path = withoutSuffix(scpLike[1].replace(/^\/+/u, "").trim()); + return path.length > 0 ? path : null; + } + return null; + } +} + export class SourceControlProvider extends Context.Service< SourceControlProvider, { @@ -111,6 +216,12 @@ export class SourceControlProvider extends Context.Service< readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly getCommitAvatarUrl: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly sha: string; + readonly authorEmail?: string | null; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..479344bf778 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -97,6 +97,14 @@ function unsupportedProvider( repository: SourceControlProvider.transportSafeSourceControlErrorValue(input.repository), detail: `No ${kind} source control provider is registered.`, }), + getCommitAvatarUrl: (input) => + new SourceControlProviderError({ + provider: kind, + operation: "getCommitAvatarUrl", + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue(input.sha), + detail: `No ${kind} source control provider is registered.`, + }), createRepository: (input) => new SourceControlProviderError({ provider: kind, @@ -179,6 +187,11 @@ function bindProviderContext( ...input, context: input.context ?? context, }), + getCommitAvatarUrl: (input) => + provider.getCommitAvatarUrl({ + ...input, + context: input.context ?? context, + }), createRepository: (input) => provider.createRepository(input), getDefaultBranch: (input) => provider.getDefaultBranch({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..b39a187e650 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -37,6 +37,7 @@ function makeProvider( createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), createRepository: () => Effect.succeed(CLONE_URLS), + getCommitAvatarUrl: () => Effect.succeed(null), getDefaultBranch: () => Effect.succeed(null), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), ...overrides, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7b5956de07f..ff114e6771e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -642,6 +642,63 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("creates a suffixed tracking branch for colliding remote refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const origin = yield* makeTmpDir("git-vcs-driver-origin-"); + const upstream = yield* makeTmpDir("git-vcs-driver-upstream-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(origin, ["init", "--bare"]); + yield* git(upstream, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", origin]); + yield* git(cwd, ["remote", "add", "upstream", upstream]); + yield* git(cwd, ["push", "origin", `${initialBranch}:refs/heads/release`]); + yield* git(cwd, ["push", "upstream", `${initialBranch}:refs/heads/release`]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["fetch", "upstream"]); + yield* git(cwd, ["checkout", "-b", "release", "--track", "upstream/release"]); + yield* git(cwd, ["checkout", initialBranch]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "origin/release", + }); + + assert.equal(result.refName, "release-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "release-1"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/release", + ); + }), + ); + + it.effect("derives tracking branches from slashful remote names", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const slashfulRemote = yield* makeTmpDir("git-vcs-driver-slashful-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(slashfulRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "my-org/upstream", slashfulRemote]); + yield* git(cwd, ["push", "my-org/upstream", `${initialBranch}:refs/heads/effect-atom`]); + yield* git(cwd, ["fetch", "my-org/upstream"]); + yield* git(cwd, ["checkout", "-b", "effect-atom"]); + yield* git(cwd, ["checkout", initialBranch]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "my-org/upstream/effect-atom", + }); + + assert.equal(result.refName, "effect-atom-1"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "effect-atom-1"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "my-org/upstream/effect-atom", + ); + }), + ); + it.effect("returns the existing refName when rename source and target match", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33eb6d40d76..398ede34bd9 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -308,7 +308,18 @@ function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): return null; } -function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { +function deriveLocalBranchNameFromRemoteRef( + branchName: string, + remoteNames: ReadonlyArray, +): string | null { + const parsedRemoteRef = parseRemoteRefWithRemoteNames( + branchName, + remoteNames.toSorted((left, right) => right.length - left.length), + ); + if (parsedRemoteRef) { + return parsedRemoteRef.branchName; + } + const separatorIndex = branchName.indexOf("/"); if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { return null; @@ -2469,7 +2480,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ) : null; - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const remoteNames = remoteExists + ? yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])) + : []; + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef( + input.refName, + remoteNames, + ); const localTrackedBranchTargetExists = remoteExists && localTrackedBranchCandidate ? yield* executeGit( @@ -2482,16 +2499,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ).pipe(Effect.map((result) => result.exitCode === 0)) : false; + const availableLocalTrackingBranch = + remoteExists && !localTrackingBranch && localTrackedBranchCandidate + ? localTrackedBranchTargetExists + ? yield* resolveAvailableBranchName(input.cwd, localTrackedBranchCandidate) + : localTrackedBranchCandidate + : null; const checkoutArgs = localInputExists ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.refName] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.refName] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.refName]; + : remoteExists && !localTrackingBranch && availableLocalTrackingBranch + ? ["checkout", "--track", "-b", availableLocalTrackingBranch, input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, diff --git a/apps/server/src/vcs/VcsLocalWatch.ts b/apps/server/src/vcs/VcsLocalWatch.ts new file mode 100644 index 00000000000..ea5142081c4 --- /dev/null +++ b/apps/server/src/vcs/VcsLocalWatch.ts @@ -0,0 +1,33 @@ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import type * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +const VCS_STATUS_WATCH_IGNORED_ROOTS = new Set([".git"]); + +export 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), + ); +} diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ee595b1f836..f910fce26b9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -13,6 +13,7 @@ import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; import type { VcsStatusLocalResult, VcsStatusRemoteResult, @@ -22,6 +23,7 @@ import type { import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as VcsProcess from "./VcsProcess.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; const baseLocalStatus: VcsStatusLocalResult = { @@ -105,6 +107,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, @@ -363,9 +416,10 @@ describe("VcsStatusBroadcaster", () => { return Effect.void; }).pipe(Effect.forkScoped); - const snapshot = yield* Deferred.await(snapshotDeferred); - yield* broadcaster.refreshStatus("/repo"); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + const snapshot = yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred).pipe( + Effect.timeout("2 seconds"), + ); assert.deepStrictEqual(snapshot, { _tag: "snapshot", @@ -379,6 +433,131 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("publishes explicit local updates even when the status summary is unchanged", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const localUpdatedDeferred = yield* Deferred.make(); + + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "localUpdated") { + return Deferred.succeed(localUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + yield* broadcaster.refreshLocalStatus("/repo"); + const localUpdated = yield* Deferred.await(localUpdatedDeferred).pipe( + Effect.timeout("2 seconds"), + ); + + assert.deepStrictEqual(localUpdated, { + _tag: "localUpdated", + local: baseLocalStatus, + } satisfies VcsStatusStreamEvent); + assert.isAtLeast(state.localStatusCalls, 2); + assert.isAtLeast(state.localInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it("parses worktree paths from porcelain output", () => { + assert.deepStrictEqual( + VcsStatusBroadcaster.parseWorktreePaths( + [ + "worktree /repo", + "HEAD abc", + "branch refs/heads/main", + "", + "worktree /repo.worktrees/feature", + "HEAD def", + "branch refs/heads/feature/source-control", + "", + ].join("\n"), + ), + ["/repo", "/repo.worktrees/feature"], + ); + }); + + it.effect("skips missing sibling worktree paths before retaining watchers", () => { + const rootDir = process.cwd(); + const siblingDir = `${rootDir}/..`; + const missingDir = `${rootDir}/.missing-worktree-for-vcs-status-test`; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const snapshotDeferred = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: rootDir }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred).pipe(Effect.timeout("2 seconds")); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 0); + assert.isFalse(yield* fileSystem.exists(missingDir)); + }).pipe( + Effect.provide( + Layer.merge( + makeTestLayer(state), + Layer.succeed(VcsProcess.VcsProcess, { + run: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: [ + `worktree ${rootDir}`, + "HEAD abc", + "branch refs/heads/main", + "", + `worktree ${siblingDir}`, + "HEAD def", + "branch refs/heads/feature/live", + "", + `worktree ${missingDir}`, + "HEAD ghi", + "branch refs/heads/feature/missing", + "", + ].join("\n"), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }), + ), + ), + ); + }); + it.effect("loads remote status once when periodic refreshes are disabled", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d1a67053273..248c9a0abce 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -6,6 +6,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"; @@ -23,6 +25,10 @@ import type { import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import { localWatchRefreshSignals, watchEventPath } from "./VcsLocalWatch.ts"; +import * as VcsProcess from "./VcsProcess.ts"; + +export { localWatchRefreshSignals, shouldIgnoreWatchEventPath } from "./VcsLocalWatch.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -133,10 +139,28 @@ interface ActiveRemotePoller { readonly subscriberCount: number; } +interface ActiveLocalWatcher { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } +const LOCAL_WATCHER_KEY_SEPARATOR = "\0"; + +function localWatcherKey(watchCwd: string, refreshCwd: string): string { + return `${watchCwd}${LOCAL_WATCHER_KEY_SEPARATOR}${refreshCwd}`; +} + +export function parseWorktreePaths(output: string): readonly string[] { + return output + .split(/\r?\n/u) + .flatMap((line) => (line.startsWith("worktree ") ? [line.slice("worktree ".length)] : [])) + .filter((worktreePath) => worktreePath.length > 0); +} + export function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, @@ -181,6 +205,8 @@ const normalizeCwd = (cwd: string) => export const make = 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), @@ -190,6 +216,7 @@ export const make = Effect.gen(function* () { ); 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, @@ -198,7 +225,11 @@ export const make = Effect.gen(function* () { }); const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + function* ( + cwd: string, + local: VcsStatusLocalResult, + options?: { publish?: boolean; forcePublish?: boolean }, + ) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, @@ -210,7 +241,10 @@ export const make = Effect.gen(function* () { ...previous, local: nextLocal, }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + return [ + options?.forcePublish === true || previous.local?.fingerprint !== nextLocal.fingerprint, + nextCache, + ] as const; }); if (options?.publish && shouldPublish) { @@ -337,10 +371,13 @@ export const make = Effect.gen(function* () { }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( - function* (cwd: string) { + function* (cwd: string, options?: { forcePublish?: boolean }) { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return yield* updateCachedLocalStatus(cwd, local, { + publish: true, + ...(options?.forcePublish === true ? { forcePublish: true } : {}), + }); }, ); @@ -348,7 +385,7 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - return yield* refreshLocalStatusCore(cwd); + return yield* refreshLocalStatusCore(cwd, { forcePublish: true }); }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( @@ -500,11 +537,166 @@ export const make = Effect.gen(function* () { } }); + const worktreeWatchPaths = Effect.fn("VcsStatusBroadcaster.worktreeWatchPaths")(function* ( + cwd: string, + ) { + if (Option.isNone(vcsProcess)) return []; + const result = yield* vcsProcess.value + .run({ + operation: "VcsStatusBroadcaster.worktrees", + command: "git", + args: ["worktree", "list", "--porcelain"], + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 1_000_000, + }) + .pipe(Effect.orElseSucceed(() => null)); + if (result === null || result.exitCode !== 0) return []; + const rootPath = path.resolve(cwd); + const candidatePaths = parseWorktreePaths(result.stdout).filter( + (worktreePath) => path.resolve(worktreePath) !== rootPath, + ); + const existingPaths = yield* Effect.forEach(candidatePaths, (worktreePath) => + fs.exists(worktreePath).pipe( + Effect.orElseSucceed(() => false), + Effect.map((exists) => (exists ? worktreePath : null)), + ), + ); + return existingPaths.filter((worktreePath): worktreePath is string => worktreePath !== null); + }); + + const makeLocalWatchLoop = (watchCwd: string, refreshCwd: string) => + localWatchRefreshSignals( + fs.watch(watchCwd).pipe( + Stream.map((event) => watchEventPath(path, watchCwd, 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: watchCwd, + 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(() => + refreshLocalStatusCore(refreshCwd, { forcePublish: true }).pipe( + Effect.ignoreCause({ log: true }), + ), + ), + Effect.ignoreCause({ log: true }), + ); + + const retainLocalWatcher = Effect.fn("VcsStatusBroadcaster.retainLocalWatcher")(function* ( + watchCwd: string, + refreshCwd: string, + ) { + const key = localWatcherKey(watchCwd, refreshCwd); + yield* SynchronizedRef.modifyEffect(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(key); + if (existing) { + const exit = existing.fiber.pollUnsafe(); + if (exit === undefined) { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(key, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextWatchers] as const); + } + return makeLocalWatchLoop(watchCwd, refreshCwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(key, { + fiber, + subscriberCount: existing.subscriberCount + 1, + }); + return [undefined, nextWatchers] as const; + }), + ); + } + + return makeLocalWatchLoop(watchCwd, refreshCwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(key, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextWatchers] as const; + }), + ); + }); + }); + + const releaseLocalWatcher = Effect.fn("VcsStatusBroadcaster.releaseLocalWatcher")(function* ( + watchCwd: string, + refreshCwd: string, + ) { + const key = localWatcherKey(watchCwd, refreshCwd); + const watcherToInterrupt = yield* SynchronizedRef.modify(watchersRef, (activeWatchers) => { + const existing = activeWatchers.get(key); + if (!existing) { + return [null, activeWatchers] as const; + } + + if (existing.subscriberCount > 1) { + const nextWatchers = new Map(activeWatchers); + nextWatchers.set(key, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextWatchers] as const; + } + + const nextWatchers = new Map(activeWatchers); + nextWatchers.delete(key); + return [existing.fiber, nextWatchers] as const; + }); + + if (watcherToInterrupt) { + yield* Fiber.interrupt(watcherToInterrupt).pipe(Effect.ignore); + } + }); + const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); + const siblingWatchCwds = yield* worktreeWatchPaths(cwd); + const watchedCwds = [cwd, ...siblingWatchCwds]; + yield* Effect.forEach( + watchedCwds, + (watchCwd) => + Effect.acquireRelease(retainLocalWatcher(watchCwd, cwd), () => + releaseLocalWatcher(watchCwd, cwd), + ), + { discard: true }, + ); + yield* Effect.yieldNow; const initialLocal = yield* getOrLoadLocalStatus(cwd); const cachedStatus = yield* getCachedStatus(cwd); const initialRemote = cachedStatus?.remote?.value ?? null; @@ -515,7 +707,10 @@ export const make = Effect.gen(function* () { cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = Effect.all([releaseRemotePoller(cwd)], { discard: true }).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 2a8be25a728..fff98bf6360 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -106,6 +106,7 @@ import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; +import * as SourceControlPanelService from "./sourceControl/SourceControlPanelService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; @@ -335,6 +336,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], @@ -456,6 +486,7 @@ const makeWsRpcLayer = ( ); const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const sourceControlPanel = yield* SourceControlPanelService.make(); const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -1792,6 +1823,224 @@ const makeWsRpcLayer = ( ), { "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.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 1b2e537c35d..cfa5fdb8a60 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -15,6 +15,7 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildThreadTurnInterruptInput, + clearThreadErrorRecord, createLocalDispatchSnapshot, deriveComposerSendState, dismissBranchMismatchForSession, @@ -23,8 +24,10 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + retainThreadKeyRecord, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldApplySourceControlMetadataUpdateResult, startNewThreadForProject, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, @@ -446,6 +449,80 @@ describe("shouldWriteThreadErrorToCurrentServerThread", () => { }); }); +describe("clearThreadErrorRecord", () => { + it("clears only the selected thread error", () => { + expect( + clearThreadErrorRecord( + { + "environment-local:thread-1": "metadata failed", + "environment-local:thread-2": "send failed", + }, + "environment-local:thread-1", + ), + ).toEqual({ + "environment-local:thread-1": null, + "environment-local:thread-2": "send failed", + }); + }); + + it("keeps the same object when the selected thread has no error", () => { + const existing = { + "environment-local:thread-1": null, + "environment-local:thread-2": "send failed", + }; + + expect(clearThreadErrorRecord(existing, "environment-local:thread-1")).toBe(existing); + expect(clearThreadErrorRecord(existing, "environment-local:thread-3")).toBe(existing); + }); +}); + +describe("retainThreadKeyRecord", () => { + it("drops stale thread keys", () => { + expect( + retainThreadKeyRecord( + { + "environment-local:thread-1": "send failed", + "environment-local:thread-2": null, + }, + new Set(["environment-local:thread-1"]), + ), + ).toEqual({ + "environment-local:thread-1": "send failed", + }); + }); + + it("preserves reference identity when no keys are pruned", () => { + const existing = { + "environment-local:thread-1": "send failed", + }; + + expect(retainThreadKeyRecord(existing, new Set(["environment-local:thread-1"]))).toBe(existing); + }); +}); + +describe("shouldApplySourceControlMetadataUpdateResult", () => { + it("allows only the latest metadata update result for a thread", () => { + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: 2, + requestSequence: 2, + }), + ).toBe(true); + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: 2, + requestSequence: 1, + }), + ).toBe(false); + expect( + shouldApplySourceControlMetadataUpdateResult({ + currentSequence: undefined, + requestSequence: 1, + }), + ).toBe(false); + }); +}); + describe("startNewThreadForProject", () => { it("starts a thread through the supplied shared handler for the active project", () => { const calls: Array<{ environmentId: EnvironmentId; projectId: ProjectId }> = []; diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4fb007b85b5..ce3f98795a1 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -114,6 +114,42 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { ); } +export function clearThreadErrorRecord( + existing: Record, + threadKey: string, +): Record { + if ((existing[threadKey] ?? null) === null) { + return existing; + } + return { + ...existing, + [threadKey]: null, + }; +} + +export function retainThreadKeyRecord( + existing: Record, + retainedThreadKeys: ReadonlySet, +): Record { + let changed = false; + const next: Record = {}; + for (const [threadKey, value] of Object.entries(existing)) { + if (retainedThreadKeys.has(threadKey)) { + next[threadKey] = value; + } else { + changed = true; + } + } + return changed ? next : existing; +} + +export function shouldApplySourceControlMetadataUpdateResult(input: { + readonly currentSequence: number | undefined; + readonly requestSequence: number; +}): boolean { + return input.currentSequence === input.requestSequence; +} + export function buildThreadTurnInterruptInput(thread: Pick): { threadId: ThreadId; turnId?: TurnId; diff --git a/apps/web/src/components/ChatView.sourceControl.test.ts b/apps/web/src/components/ChatView.sourceControl.test.ts new file mode 100644 index 00000000000..da975ae4a5c --- /dev/null +++ b/apps/web/src/components/ChatView.sourceControl.test.ts @@ -0,0 +1,405 @@ +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { DraftId } from "../composerDraftStore"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; +import { + type RightPanelSurface, + selectActiveRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../rightPanelStore"; +import { retainThreadKeyRecord } from "./ChatView.logic"; +import { + filterVisibleSourceControlSurfaces, + isSourceControlAvailable, + resolveSourceControlPanelTarget, + resolveVisibleSourceControlSurface, + resolveSourceControlDraftMetadataTarget, + retargetOpenSourceControlSurface, + runSourceControlServerMetadataUpdate, + selectSourceControlMetadataError, + sourceControlMetadataErrorFromFailure, +} from "./ChatView.sourceControl"; + +const environmentId = EnvironmentId.make("environment-local"); +const threadId = ThreadId.make("thread-1"); +const activeThreadRef = { environmentId, threadId }; +const metadata = { + branch: "feature/source-control", + worktreePath: "/tmp/source-control", +}; +const expectedBranch = "feature/previous-ref"; + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); +}); + +describe("sourceControlMetadataErrorFromFailure", () => { + it("formats structured object errors without collapsing to object text", () => { + expect( + sourceControlMetadataErrorFromFailure({ + code: "ECONNRESET", + message: "metadata update failed", + }), + ).toBe("metadata update failed (ECONNRESET)"); + expect(sourceControlMetadataErrorFromFailure({ detail: "raw provider failure" })).toBe( + '{"detail":"raw provider failure"}', + ); + }); +}); + +describe("resolveSourceControlDraftMetadataTarget", () => { + it("prefers a draft id and falls back to the active thread ref", () => { + const draftId = DraftId.make("draft-1"); + + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef: null, draftId })).toBe( + draftId, + ); + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef, draftId: null })).toBe( + activeThreadRef, + ); + expect(resolveSourceControlDraftMetadataTarget({ activeThreadRef: null, draftId: null })).toBe( + null, + ); + }); +}); + +describe("source control right panel surface visibility", () => { + const sourceControlSurface = { id: "source-control", kind: "source-control" } as const; + const planSurface = { id: "plan", kind: "plan" } as const; + const assertActiveSourceControlSurface = ( + phase: string, + expectedSurfaces: readonly RightPanelSurface[], + ) => { + const byThreadKey = useRightPanelStore.getState().byThreadKey; + const panelState = selectThreadRightPanelState(byThreadKey, activeThreadRef); + const activeSurface = selectActiveRightPanelSurface(byThreadKey, activeThreadRef); + const visibleSurfaces = filterVisibleSourceControlSurfaces({ + sourceControlAvailable: true, + surfaces: panelState.surfaces, + }); + const visibleActiveSurface = resolveVisibleSourceControlSurface({ + sourceControlAvailable: true, + surface: activeSurface, + visibleSurfaces, + }); + + expect(panelState.isOpen, `${phase}: panel is open`).toBe(true); + expect(panelState.activeSurfaceId, `${phase}: Source Control is active`).toBe("source-control"); + expect(panelState.surfaces, `${phase}: surface list`).toEqual(expectedSurfaces); + expect(visibleSurfaces, `${phase}: Source Control is visible`).toContainEqual( + sourceControlSurface, + ); + expect(visibleActiveSurface, `${phase}: visible active surface`).toEqual(sourceControlSurface); + }; + + it("requires both a thread ref and Git cwd before making Source Control available", () => { + expect(isSourceControlAvailable({ activeThreadRef, gitCwd: "/repo" })).toBe(true); + expect(isSourceControlAvailable({ activeThreadRef: null, gitCwd: "/repo" })).toBe(false); + expect(isSourceControlAvailable({ activeThreadRef, gitCwd: null })).toBe(false); + }); + + it("hides unavailable Source Control surfaces without affecting other surfaces", () => { + expect( + filterVisibleSourceControlSurfaces({ + sourceControlAvailable: false, + surfaces: [sourceControlSurface, planSurface], + }), + ).toEqual([planSurface]); + + const surfaces = [sourceControlSurface, planSurface]; + expect( + filterVisibleSourceControlSurfaces({ + sourceControlAvailable: true, + surfaces, + }), + ).toBe(surfaces); + }); + + it("falls back from an unavailable active Source Control surface to another visible surface", () => { + expect( + resolveVisibleSourceControlSurface({ + sourceControlAvailable: false, + surface: sourceControlSurface, + visibleSurfaces: [planSurface], + }), + ).toBe(planSurface); + expect( + resolveVisibleSourceControlSurface({ + sourceControlAvailable: false, + surface: planSurface, + visibleSurfaces: [planSurface], + }), + ).toBe(planSurface); + expect( + resolveVisibleSourceControlSurface({ + sourceControlAvailable: true, + surface: sourceControlSurface, + visibleSurfaces: [sourceControlSurface, planSurface], + }), + ).toBe(sourceControlSurface); + expect( + resolveVisibleSourceControlSurface({ + sourceControlAvailable: false, + surface: sourceControlSurface, + visibleSurfaces: [], + }), + ).toBe(null); + }); + + it("retargets an open singleton surface across grouped project drafts without leaking errors", () => { + const sharedDraftThreadId = ThreadId.make("grouped-draft"); + const groupedProjectARef = scopeThreadRef( + EnvironmentId.make("environment-project-a"), + sharedDraftThreadId, + ); + const groupedProjectBRef = scopeThreadRef( + EnvironmentId.make("environment-project-b"), + sharedDraftThreadId, + ); + const groupedProjectACwd = "/repos/grouped-project-a"; + const groupedProjectBCwd = "/repos/grouped-project-b"; + + useRightPanelStore.getState().open(groupedProjectARef, "source-control"); + + const initialByThreadKey = useRightPanelStore.getState().byThreadKey; + expect(selectActiveRightPanelSurface(initialByThreadKey, groupedProjectBRef)).toBeNull(); + + retargetOpenSourceControlSurface({ + currentThreadRef: groupedProjectARef, + nextThreadRef: groupedProjectBRef, + }); + + const byThreadKey = useRightPanelStore.getState().byThreadKey; + expect(selectThreadRightPanelState(byThreadKey, groupedProjectARef).surfaces).toEqual([ + sourceControlSurface, + ]); + expect(selectThreadRightPanelState(byThreadKey, groupedProjectBRef).surfaces).toEqual([ + sourceControlSurface, + ]); + const projectASurface = selectActiveRightPanelSurface(byThreadKey, groupedProjectARef); + const projectBSurface = selectActiveRightPanelSurface(byThreadKey, groupedProjectBRef); + expect( + resolveSourceControlPanelTarget({ + activeThreadRef: groupedProjectARef, + gitCwd: groupedProjectACwd, + surface: projectASurface, + }), + ).toEqual({ + environmentId: groupedProjectARef.environmentId, + threadId: groupedProjectARef.threadId, + cwd: groupedProjectACwd, + }); + expect( + resolveSourceControlPanelTarget({ + activeThreadRef: groupedProjectBRef, + gitCwd: groupedProjectBCwd, + surface: projectBSurface, + }), + ).toEqual({ + environmentId: groupedProjectBRef.environmentId, + threadId: groupedProjectBRef.threadId, + cwd: groupedProjectBCwd, + }); + + const projectAThreadKey = scopedThreadKey(groupedProjectARef); + const projectBThreadKey = scopedThreadKey(groupedProjectBRef); + const metadataErrors = { + [projectAThreadKey]: "stale project A metadata failure", + }; + + expect(selectSourceControlMetadataError(metadataErrors, projectAThreadKey)).toBe( + "stale project A metadata failure", + ); + expect(selectSourceControlMetadataError(metadataErrors, projectBThreadKey)).toBeNull(); + expect(retainThreadKeyRecord(metadataErrors, new Set([projectBThreadKey]))).toEqual({}); + }); + + it("does not update the store when retargeting to the same scoped thread", () => { + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + const initialByThreadKey = useRightPanelStore.getState().byThreadKey; + + retargetOpenSourceControlSurface({ + currentThreadRef: activeThreadRef, + nextThreadRef: activeThreadRef, + }); + + expect(useRightPanelStore.getState().byThreadKey).toBe(initialByThreadKey); + }); + + it("keeps Source Control stable while preview stores change independently", () => { + const previewTabId = "background-preview"; + const previewSurface = { + id: `browser:${previewTabId}`, + kind: "preview", + resourceId: previewTabId, + } as const; + + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + assertActiveSourceControlSurface("after opening Source Control", [sourceControlSurface]); + + usePreviewMiniPlayerStore.getState().open(activeThreadRef, previewTabId); + assertActiveSourceControlSurface("after opening the mini-player", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toMatchObject({ tabId: previewTabId }); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, [previewTabId]); + assertActiveSourceControlSurface("after adding the browser surface", [ + sourceControlSurface, + previewSurface, + ]); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, []); + assertActiveSourceControlSurface("after removing the browser surface", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toMatchObject({ tabId: previewTabId }); + + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + assertActiveSourceControlSurface("after closing the mini-player", [sourceControlSurface]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toBeNull(); + }); + + it("keeps Source Control stable when the mini-player closes before browser reconciliation", () => { + const previewTabId = "background-preview"; + const previewSurface = { + id: `browser:${previewTabId}`, + kind: "preview", + resourceId: previewTabId, + } as const; + + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + usePreviewMiniPlayerStore.getState().open(activeThreadRef, previewTabId); + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, [previewTabId]); + + usePreviewMiniPlayerStore.getState().close(activeThreadRef); + assertActiveSourceControlSurface("after closing the mini-player first", [ + sourceControlSurface, + previewSurface, + ]); + expect( + selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + activeThreadRef, + ), + ).toBeNull(); + + useRightPanelStore.getState().reconcileBrowserSurfaces(activeThreadRef, []); + assertActiveSourceControlSurface("after reconciling the browser surface", [ + sourceControlSurface, + ]); + }); +}); + +describe("runSourceControlServerMetadataUpdate", () => { + it("sends server-thread metadata and reports success", async () => { + const calls: unknown[] = []; + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async (input) => { + calls.push(input); + return AsyncResult.success(undefined); + }, + }); + + expect(result).toEqual({ _tag: "Success" }); + expect(calls).toEqual([ + { + environmentId, + input: { + threadId, + branch: metadata.branch, + expectedBranch, + worktreePath: metadata.worktreePath, + }, + }, + ]); + }); + + it("drops stale results after a newer server-thread metadata request", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 2, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => AsyncResult.failure(Cause.fail("old failure")), + }); + + expect(result).toEqual({ _tag: "Stale" }); + }); + + it("drops stale thrown errors after a newer server-thread metadata request", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 2, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => { + throw { code: "NETWORK", message: "old network failure" }; + }, + }); + + expect(result).toEqual({ _tag: "Stale" }); + }); + + it("converts thrown update errors into controlled metadata failures", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => { + throw { code: "NETWORK", message: "network failed" }; + }, + }); + + expect(result).toEqual({ + _tag: "Failure", + message: "network failed (NETWORK)", + }); + }); + + it("keeps interrupted command results silent", async () => { + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef, + expectedBranch, + getCurrentSequence: () => 1, + metadata, + requestSequence: 1, + updateThreadMetadata: async () => AsyncResult.failure(Cause.interrupt(1)), + }); + + expect(result).toEqual({ _tag: "Interrupted" }); + }); + + it("uses the scoped thread key for server update sequencing callers", () => { + expect(scopedThreadKey(activeThreadRef)).toBe("environment-local:thread-1"); + }); +}); diff --git a/apps/web/src/components/ChatView.sourceControl.ts b/apps/web/src/components/ChatView.sourceControl.ts new file mode 100644 index 00000000000..d5cfede2679 --- /dev/null +++ b/apps/web/src/components/ChatView.sourceControl.ts @@ -0,0 +1,383 @@ +import type { UpdateThreadMetadataInput } from "@t3tools/client-runtime/operations"; +import { type EnvironmentId, type ScopedThreadRef } from "@t3tools/contracts"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { ComposerThreadTarget, DraftId } from "../composerDraftStore"; +import { + selectActiveRightPanelSurface, + type RightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + clearThreadErrorRecord, + retainThreadKeyRecord, + shouldApplySourceControlMetadataUpdateResult, +} from "./ChatView.logic"; + +type UpdateThreadMetadata = (input: { + readonly environmentId: EnvironmentId; + readonly input: UpdateThreadMetadataInput; +}) => Promise>; + +type SetDraftThreadContext = ( + target: ComposerThreadTarget, + context: { + readonly branch?: string | null; + readonly worktreePath?: string | null; + }, +) => void; + +interface SourceControlThreadRefChange { + readonly branch: string | null; + readonly worktreePath: string | null; +} + +interface UseSourceControlThreadMetadataRoutingInput { + readonly activeThreadRef: ScopedThreadRef | null; + readonly activeThreadKey: string | null; + readonly draftId: DraftId | null; + readonly expectedBranch: string | null; + readonly existingThreadKeys: ReadonlySet; + readonly isServerThread: boolean; + readonly setDraftThreadContext: SetDraftThreadContext; + readonly updateThreadMetadata: UpdateThreadMetadata; +} + +interface SourceControlThreadMetadataRouting { + readonly sourceControlMetadataError: string | null; + readonly clearActiveSourceControlMetadataError: () => void; + readonly handleSourceControlThreadRefChange: ( + input: SourceControlThreadRefChange, + ) => Promise; +} + +interface UseSourceControlRightPanelSurfaceInput { + readonly activeRightPanelSurface: RightPanelSurface | null; + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; + readonly rightPanelSurfaces: readonly RightPanelSurface[]; +} + +interface SourceControlRightPanelSurfaceState { + readonly addSourceControlSurface: () => void; + readonly sourceControlAvailable: boolean; + readonly visibleActiveRightPanelSurface: RightPanelSurface | null; + readonly visibleRightPanelSurfaces: readonly RightPanelSurface[]; +} + +interface SourceControlPanelTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ScopedThreadRef["threadId"]; + readonly cwd: string; +} + +interface SourceControlServerMetadataUpdateInput { + readonly activeThreadRef: ScopedThreadRef; + readonly expectedBranch: string | null; + readonly metadata: SourceControlThreadRefChange; + readonly requestSequence: number; + readonly getCurrentSequence: () => number | undefined; + readonly updateThreadMetadata: UpdateThreadMetadata; +} + +type SourceControlServerMetadataUpdateResult = + | { + readonly _tag: "Success"; + } + | { + readonly _tag: "Stale"; + } + | { + readonly _tag: "Interrupted"; + } + | { + readonly _tag: "Failure"; + readonly message: string; + }; + +export function sourceControlMetadataErrorFromFailure(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error instanceof Error) { + return error.message; + } + if (error && typeof error === "object") { + const message = "message" in error ? error.message : null; + const code = "code" in error ? error.code : null; + if (typeof message === "string" && message.length > 0) { + return typeof code === "string" && code.length > 0 ? `${message} (${code})` : message; + } + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== "{}") { + return serialized; + } + } catch { + return "Failed to update thread source control."; + } + } + return "Failed to update thread source control."; +} + +export function resolveSourceControlDraftMetadataTarget(input: { + readonly draftId: DraftId | null; + readonly activeThreadRef: ScopedThreadRef | null; +}): ComposerThreadTarget | null { + return input.draftId ?? input.activeThreadRef; +} + +export function isSourceControlAvailable(input: { + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; +}): boolean { + return input.activeThreadRef !== null && input.gitCwd !== null; +} + +export function filterVisibleSourceControlSurfaces(input: { + readonly surfaces: readonly RightPanelSurface[]; + readonly sourceControlAvailable: boolean; +}): readonly RightPanelSurface[] { + return input.sourceControlAvailable + ? input.surfaces + : input.surfaces.filter((surface) => surface.kind !== "source-control"); +} + +export function resolveVisibleSourceControlSurface(input: { + readonly surface: RightPanelSurface | null; + readonly sourceControlAvailable: boolean; + readonly visibleSurfaces: readonly RightPanelSurface[]; +}): RightPanelSurface | null { + return input.surface?.kind === "source-control" && !input.sourceControlAvailable + ? (input.visibleSurfaces[0] ?? null) + : input.surface; +} + +export function resolveSourceControlPanelTarget(input: { + readonly activeThreadRef: ScopedThreadRef | null; + readonly gitCwd: string | null; + readonly surface: RightPanelSurface | null; +}): SourceControlPanelTarget | null { + if (input.surface?.kind !== "source-control" || !input.activeThreadRef || !input.gitCwd) { + return null; + } + return { + environmentId: input.activeThreadRef.environmentId, + threadId: input.activeThreadRef.threadId, + cwd: input.gitCwd, + }; +} + +export function selectSourceControlMetadataError( + metadataErrorsByThreadKey: Readonly>, + activeThreadKey: string | null, +): string | null { + return activeThreadKey === null ? null : (metadataErrorsByThreadKey[activeThreadKey] ?? null); +} + +export function retargetOpenSourceControlSurface(input: { + readonly currentThreadRef: ScopedThreadRef; + readonly nextThreadRef: ScopedThreadRef; +}): void { + if (scopedThreadKey(input.currentThreadRef) === scopedThreadKey(input.nextThreadRef)) return; + const store = useRightPanelStore.getState(); + const activeSurface = selectActiveRightPanelSurface(store.byThreadKey, input.currentThreadRef); + if (activeSurface?.kind !== "source-control") return; + store.open(input.nextThreadRef, "source-control"); +} + +export async function runSourceControlServerMetadataUpdate( + input: SourceControlServerMetadataUpdateInput, +): Promise { + const { + activeThreadRef, + expectedBranch, + getCurrentSequence, + metadata, + requestSequence, + updateThreadMetadata, + } = input; + let result: AtomCommandResult; + try { + result = await updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + branch: metadata.branch, + expectedBranch, + worktreePath: metadata.worktreePath, + }, + }); + } catch (error) { + if ( + !shouldApplySourceControlMetadataUpdateResult({ + currentSequence: getCurrentSequence(), + requestSequence, + }) + ) { + return { _tag: "Stale" }; + } + return { + _tag: "Failure", + message: sourceControlMetadataErrorFromFailure(error), + }; + } + + if ( + !shouldApplySourceControlMetadataUpdateResult({ + currentSequence: getCurrentSequence(), + requestSequence, + }) + ) { + return { _tag: "Stale" }; + } + if (result._tag === "Success") { + return { _tag: "Success" }; + } + if (isAtomCommandInterrupted(result)) { + return { _tag: "Interrupted" }; + } + return { + _tag: "Failure", + message: sourceControlMetadataErrorFromFailure(squashAtomCommandFailure(result)), + }; +} + +export function useSourceControlRightPanelSurfaceState( + input: UseSourceControlRightPanelSurfaceInput, +): SourceControlRightPanelSurfaceState { + const { activeRightPanelSurface, activeThreadRef, gitCwd, rightPanelSurfaces } = input; + const sourceControlAvailable = isSourceControlAvailable({ activeThreadRef, gitCwd }); + const visibleRightPanelSurfaces = useMemo( + () => + filterVisibleSourceControlSurfaces({ + sourceControlAvailable, + surfaces: rightPanelSurfaces, + }), + [rightPanelSurfaces, sourceControlAvailable], + ); + const visibleActiveRightPanelSurface = resolveVisibleSourceControlSurface({ + sourceControlAvailable, + surface: activeRightPanelSurface, + visibleSurfaces: visibleRightPanelSurfaces, + }); + const addSourceControlSurface = useCallback(() => { + if (!activeThreadRef || !sourceControlAvailable) return; + useRightPanelStore.getState().open(activeThreadRef, "source-control"); + }, [activeThreadRef, sourceControlAvailable]); + + return { + addSourceControlSurface, + sourceControlAvailable, + visibleActiveRightPanelSurface, + visibleRightPanelSurfaces, + }; +} + +export function useSourceControlThreadMetadataRouting( + input: UseSourceControlThreadMetadataRoutingInput, +): SourceControlThreadMetadataRouting { + const { + activeThreadKey, + activeThreadRef, + draftId, + expectedBranch, + existingThreadKeys, + isServerThread, + setDraftThreadContext, + updateThreadMetadata, + } = input; + // Keep sequence values for the hook lifetime so a reopened thread key cannot reuse the + // request number of an older update that is still resolving. + const metadataUpdateSequenceByThreadKeyRef = useRef>({}); + const [metadataErrorsByThreadKey, setMetadataErrorsByThreadKey] = useState< + Record + >({}); + const sourceControlMetadataError = selectSourceControlMetadataError( + metadataErrorsByThreadKey, + activeThreadKey, + ); + const activeThreadEnvironmentId = activeThreadRef?.environmentId ?? null; + const activeThreadId = activeThreadRef?.threadId ?? null; + const activeThreadMetadataRef = useMemo(() => { + if (activeThreadEnvironmentId === null || activeThreadId === null) return null; + return { + environmentId: activeThreadEnvironmentId, + threadId: activeThreadId, + }; + }, [activeThreadEnvironmentId, activeThreadId]); + + useEffect(() => { + setMetadataErrorsByThreadKey((existing) => retainThreadKeyRecord(existing, existingThreadKeys)); + }, [existingThreadKeys]); + + const clearActiveSourceControlMetadataError = useCallback(() => { + // Draft metadata changes are local store updates and do not create dismissible metadata errors. + if (!isServerThread || activeThreadKey === null) return; + setMetadataErrorsByThreadKey((existing) => clearThreadErrorRecord(existing, activeThreadKey)); + }, [activeThreadKey, isServerThread]); + + const handleSourceControlThreadRefChange = useCallback( + async (metadata: SourceControlThreadRefChange) => { + if (!isServerThread) { + const target = resolveSourceControlDraftMetadataTarget({ + activeThreadRef: activeThreadMetadataRef, + draftId, + }); + if (!target) return; + setDraftThreadContext(target, { + branch: metadata.branch, + worktreePath: metadata.worktreePath, + }); + return; + } + + if (!activeThreadMetadataRef) return; + const targetThreadKey = scopedThreadKey(activeThreadMetadataRef); + // This counter intentionally stays monotonic per live thread key: reusing values while an + // older async update is still pending would let stale results match the latest request. + const requestSequence = + (metadataUpdateSequenceByThreadKeyRef.current[targetThreadKey] ?? 0) + 1; + metadataUpdateSequenceByThreadKeyRef.current[targetThreadKey] = requestSequence; + const result = await runSourceControlServerMetadataUpdate({ + activeThreadRef: activeThreadMetadataRef, + expectedBranch, + getCurrentSequence: () => metadataUpdateSequenceByThreadKeyRef.current[targetThreadKey], + metadata, + requestSequence, + updateThreadMetadata, + }); + if (result._tag === "Success") { + setMetadataErrorsByThreadKey((existing) => + clearThreadErrorRecord(existing, targetThreadKey), + ); + return; + } + if (result._tag === "Stale" || result._tag === "Interrupted") return; + setMetadataErrorsByThreadKey((existing) => ({ + ...existing, + [targetThreadKey]: result.message, + })); + }, + [ + activeThreadMetadataRef, + draftId, + expectedBranch, + isServerThread, + setDraftThreadContext, + updateThreadMetadata, + ], + ); + + return { + clearActiveSourceControlMetadataError, + handleSourceControlThreadRefChange, + sourceControlMetadataError, + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bdde62a3e0d..c1284b0c371 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -275,6 +275,12 @@ import { startNewThreadForProject, waitForStartedServerThread, } from "./ChatView.logic"; +import { + resolveSourceControlPanelTarget, + retargetOpenSourceControlSurface, + useSourceControlRightPanelSurfaceState, + useSourceControlThreadMetadataRouting, +} from "./ChatView.sourceControl"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; @@ -386,6 +392,11 @@ const PreviewPanel = lazy(() => ); const DiffPanel = lazy(() => import("./DiffPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const SourceControlPanel = lazy(() => + import("./source-control/SourceControlPanel").then((module) => ({ + default: module.SourceControlPanel, + })), +); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", @@ -1423,9 +1434,6 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; - const threadError = isServerThread - ? (localServerError ?? serverThread?.session?.lastError ?? null) - : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; @@ -1538,10 +1546,37 @@ function ChatViewContent(props: ChatViewProps) { const planSidebarOpen = activeRightPanelKind === "plan"; - const existingOpenTerminalThreadKeys = useMemo(() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); + const existingThreadKeys = useMemo(() => { + const threadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); + if (activeThreadKey) { + threadKeys.add(activeThreadKey); + } + return threadKeys; + }, [activeThreadKey, draftThreadKeys, serverThreadKeys]); + const existingOpenTerminalThreadKeys = useMemo( + () => openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)), + [existingThreadKeys, openTerminalThreadKeys], + ); + const { + clearActiveSourceControlMetadataError, + handleSourceControlThreadRefChange, + sourceControlMetadataError, + } = useSourceControlThreadMetadataRouting({ + activeThreadKey, + activeThreadRef, + draftId, + expectedBranch: serverThread?.branch ?? null, + existingThreadKeys, + isServerThread, + setDraftThreadContext, + updateThreadMetadata, + }); + const threadError = isServerThread + ? (localServerError ?? sourceControlMetadataError ?? serverThread?.session?.lastError ?? null) + : localDraftError; + const isThreadErrorDismissible = isServerThread + ? localServerError != null || sourceControlMetadataError != null + : localDraftError !== null; const activeLatestTurn = activeThread?.latestTurn ?? null; const sourcePlanThreadRef = useMemo(() => { const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; @@ -1605,9 +1640,10 @@ function ChatViewContent(props: ChatViewProps) { const handleFilePendingChange = useCallback( (relativePath: string, pending: boolean) => { if (!activeProjectKey) return; + const cwd = activeFileSurface?.cwd; setPendingFileSurfaceIdsByProject((currentByProject) => { const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; + const surfaceId = cwd ? `file:${cwd}:${relativePath}` : `file:${relativePath}`; if (current.has(surfaceId) === pending) return currentByProject; const next = new Set(current); if (pending) next.add(surfaceId); @@ -1618,7 +1654,7 @@ function ChatViewContent(props: ChatViewProps) { return nextByProject; }); }, - [activeProjectKey], + [activeFileSurface?.cwd, activeProjectKey], ); const configuredPreviewUrls = useMemo( () => getConfiguredPreviewUrls(activeProject?.scripts), @@ -1680,17 +1716,22 @@ function ChatViewContent(props: ChatViewProps) { projectId: ProjectId; label: string; isPrimary: boolean; + cwd: string; + connected: boolean; }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; + const environment = environmentById.get(p.environmentId); + const label = environment?.label ?? p.environmentId; envs.push({ environmentId: p.environmentId, projectId: p.id, label, isPrimary, + cwd: p.workspaceRoot, + connected: environment?.connection.phase === "connected", }); } // Sort: primary first, then alphabetical @@ -2362,6 +2403,22 @@ function ChatViewContent(props: ChatViewProps) { input: { cwd: gitStatusCwd }, }), ); + const { + addSourceControlSurface, + sourceControlAvailable, + visibleActiveRightPanelSurface, + visibleRightPanelSurfaces, + } = useSourceControlRightPanelSurfaceState({ + activeRightPanelSurface, + activeThreadRef, + gitCwd, + rightPanelSurfaces: rightPanelState.surfaces, + }); + const sourceControlPanelTarget = resolveSourceControlPanelTarget({ + activeThreadRef, + gitCwd, + surface: visibleActiveRightPanelSurface, + }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); // Prefer an instance-id match so a custom Codex instance (e.g. @@ -2467,11 +2524,17 @@ function ChatViewContent(props: ChatViewProps) { (env) => env.environmentId === nextEnvironmentId, ); if (!target) return; + if (activeThreadRef) { + retargetOpenSourceControlSurface({ + currentThreadRef: activeThreadRef, + nextThreadRef: scopeThreadRef(target.environmentId, activeThreadRef.threadId), + }); + } setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), }); }, - [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], + [activeThreadRef, draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], ); const activeTerminalGroup = @@ -3035,6 +3098,11 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, planSidebarOpen, ]); + + const dismissThreadError = useCallback(() => { + setThreadError(activeThreadId, null); + clearActiveSourceControlMetadataError(); + }, [activeThreadId, clearActiveSourceControlMetadataError, setThreadError]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -3042,9 +3110,11 @@ function ChatViewContent(props: ChatViewProps) { const openFileSurface = useCallback( (relativePath: string) => { if (!activeThreadRef || !activeProject) return; - useRightPanelStore.getState().openFile(activeThreadRef, relativePath); + useRightPanelStore + .getState() + .openFile(activeThreadRef, relativePath, undefined, activeFileSurface?.cwd); }, - [activeProject, activeThreadRef], + [activeFileSurface?.cwd, activeProject, activeThreadRef], ); const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; @@ -5562,20 +5632,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") && + ) : sourceControlPanelTarget ? ( + + + + ) : (visibleActiveRightPanelSurface?.kind === "files" || + visibleActiveRightPanelSurface?.kind === "file") && activeProject && activeWorkspaceRoot ? ( setThreadError(activeThread.id, null)} + {...(isThreadErrorDismissible ? { onDismiss: dismissThreadError } : {})} /> {/* Main content area with optional plan sidebar */}
@@ -6023,8 +6108,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} @@ -6050,8 +6137,8 @@ function ChatViewContent(props: ChatViewProps) { {rightPanelContent} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx new file mode 100644 index 00000000000..5e36b11ee4c --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { + ADD_SURFACE_EMPTY_STATE_ORDER, + ADD_SURFACE_MENU_ORDER, + buildAddSurfaceActions, +} from "./RightPanelTabs"; + +function actionProps() { + return { + onAddBrowser: vi.fn(), + onAddTerminal: vi.fn(), + onAddDiff: vi.fn(), + onAddFiles: vi.fn(), + onAddSourceControl: vi.fn(), + browserAvailable: true, + diffAvailable: false, + filesAvailable: true, + sourceControlAvailable: true, + }; +} + +describe("RightPanelTabs add-surface actions", () => { + it("keeps Version Control first in the empty state", () => { + const actions = buildAddSurfaceActions(actionProps(), ADD_SURFACE_EMPTY_STATE_ORDER); + + expect(actions.map((action) => action.id)).toEqual([ + "source-control", + "browser", + "terminal", + "files", + "diff", + ]); + }); + + it("preserves the legacy add-menu order", () => { + const actions = buildAddSurfaceActions(actionProps(), ADD_SURFACE_MENU_ORDER); + + expect(actions.map((action) => action.label)).toEqual([ + "Browser", + "Terminal", + "Files", + "Diff", + "Version Control", + ]); + }); + + it("shares enabled callbacks and disabled reasons across consumers", () => { + const props = actionProps(); + const actions = buildAddSurfaceActions(props); + const browser = actions.find((action) => action.id === "browser"); + const diff = actions.find((action) => action.id === "diff"); + + expect(browser?.available).toBe(true); + browser?.onClick(); + expect(props.onAddBrowser).toHaveBeenCalledTimes(1); + expect(diff?.available).toBe(false); + expect(diff?.disabledReason).toBe( + "Diff is only available for server threads in Git repositories.", + ); + }); +}); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 83524009c19..ae11dcef8c3 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,12 +1,23 @@ 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, + type LucideIcon, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -44,9 +55,11 @@ interface RightPanelTabsProps { onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; + onAddSourceControl: () => void; browserAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; + sourceControlAvailable: boolean; children: ReactNode; } @@ -54,49 +67,67 @@ 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 AddPanelSurfaceId = "source-control" | "browser" | "terminal" | "files" | "diff"; + +type AddPanelSurfaceAction = { + readonly id: AddPanelSurfaceId; + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; + readonly available: boolean; + readonly disabledReason?: string; + readonly onClick: () => void; +}; + type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; -function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { - return ( - - - {props.reason} - - ); -} +type AddPanelSurfaceActionProps = Pick< + RightPanelTabsProps, + | "onAddBrowser" + | "onAddTerminal" + | "onAddDiff" + | "onAddFiles" + | "onAddSourceControl" + | "browserAvailable" + | "diffAvailable" + | "filesAvailable" + | "sourceControlAvailable" +>; -function SurfaceMenuItem(props: { - available: boolean; - disabledReason?: string; - onClick: () => void; - children: ReactNode; -}) { - const item = ( - - {props.children} - - ); - if (props.available || !props.disabledReason) return item; - return ; -} +export const ADD_SURFACE_EMPTY_STATE_ORDER: readonly AddPanelSurfaceId[] = [ + "source-control", + "browser", + "terminal", + "files", + "diff", +]; -function RightPanelEmptyState(props: { - onAddBrowser: () => void; - onAddTerminal: () => void; - onAddDiff: () => void; - onAddFiles: () => void; - browserAvailable: boolean; - diffAvailable: boolean; - filesAvailable: boolean; -}) { - const actions = [ - { +export const ADD_SURFACE_MENU_ORDER: readonly AddPanelSurfaceId[] = [ + "browser", + "terminal", + "files", + "diff", + "source-control", +]; + +function buildAddSurfaceActionMap( + props: AddPanelSurfaceActionProps, +): Readonly> { + return { + "source-control": { + id: "source-control", + label: "Version Control", + description: "Review repository changes and sync state.", + icon: GitBranch, + available: props.sourceControlAvailable, + disabledReason: SURFACE_DISABLED_REASONS.sourceControl, + onClick: props.onAddSourceControl, + }, + browser: { + id: "browser", label: "Browser", description: "Open a local app or URL.", icon: Globe2, @@ -104,15 +135,16 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, }, - { + terminal: { + id: "terminal", label: "Terminal", description: "Start a shell in this workspace.", icon: TerminalSquare, available: true, - disabledReason: null, onClick: props.onAddTerminal, }, - { + files: { + id: "files", label: "Files", description: "Browse and read workspace files.", icon: Files, @@ -120,7 +152,8 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, }, - { + diff: { + id: "diff", label: "Diff", description: "Review changes in this thread.", icon: FileDiff, @@ -128,8 +161,46 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, - ] as const; + }; +} +export function buildAddSurfaceActions( + props: AddPanelSurfaceActionProps, + order: readonly AddPanelSurfaceId[] = ADD_SURFACE_EMPTY_STATE_ORDER, +): readonly AddPanelSurfaceAction[] { + const actions = buildAddSurfaceActionMap(props); + return order.map((id) => actions[id]); +} + +function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { + return ( + + + {props.reason} + + ); +} + +function SurfaceMenuItem(props: { + available: boolean; + disabledReason: string | undefined; + onClick: () => void; + children: ReactNode; +}) { + const item = ( + + {props.children} + + ); + if (props.available || !props.disabledReason) return item; + return ; +} + +function RightPanelEmptyState(props: { actions: readonly AddPanelSurfaceAction[] }) { return (
@@ -140,7 +211,7 @@ function RightPanelEmptyState(props: {

- {actions.map((action) => { + {props.actions.map((action) => { const Icon = action.icon; const content = ( <> @@ -154,7 +225,7 @@ function RightPanelEmptyState(props: { if (action.available) { return ( ); + if (!action.disabledReason) { + return
{disabledCard()}
; + } return ( {disabledCard()}} /> ); })} @@ -205,6 +282,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"; @@ -266,6 +345,8 @@ function SurfaceIcon({ return ; case "plan": return ; + case "source-control": + return ; } } @@ -273,6 +354,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); + const emptyStateActions = useMemo( + () => buildAddSurfaceActions(props, ADD_SURFACE_EMPTY_STATE_ORDER), + [ + props.browserAvailable, + props.diffAvailable, + props.filesAvailable, + props.sourceControlAvailable, + props.onAddBrowser, + props.onAddDiff, + props.onAddFiles, + props.onAddSourceControl, + props.onAddTerminal, + ], + ); + const menuActions = useMemo( + () => buildAddSurfaceActions(props, ADD_SURFACE_MENU_ORDER), + [ + props.browserAvailable, + props.diffAvailable, + props.filesAvailable, + props.sourceControlAvailable, + props.onAddBrowser, + props.onAddDiff, + props.onAddFiles, + props.onAddSourceControl, + props.onAddTerminal, + ], + ); const handleTabContextMenu = useCallback( async (event: ReactMouseEvent, surface: RightPanelSurface) => { @@ -442,34 +551,20 @@ export function RightPanelTabs(props: RightPanelTabsProps) { - - - Browser - - - - Terminal - - - - Files - - - - Diff - + {menuActions.map((action) => { + const Icon = action.icon; + return ( + + + {action.label} + + ); + })} ) : null} @@ -479,15 +574,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( - + ) : ( props.children )} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3d314fd1ac2..2c078ff60dc 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -155,7 +155,13 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./u import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; -import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { + Tooltip, + TooltipCardPopup, + TooltipPopup, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; // Settled-tail paging: recent history is the common lookup; the deep tail @@ -239,11 +245,10 @@ function SidebarV2ThreadTooltip({ } | null; }) { return ( -
@@ -299,7 +304,7 @@ function SidebarV2ThreadTooltip({ ) : null}
- + ); } diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 764f682fbc5..ad1fc8dedeb 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -63,6 +63,32 @@ const SOURCE_CONTROL_PROVIDER_ICONS: Partial([ + "github", + "gitlab", + "azure-devops", + "bitbucket", +]); + +const SOURCE_CONTROL_AVATAR_PROVIDER_DESCRIPTIONS: Record = + { + github: "Fetch GitHub account avatars for commit rows from the configured GitHub remote.", + gitlab: + "Fetch GitLab account avatars for commit rows using GitLab's official Avatar API. GitLab may return external avatar-service URLs such as Gravatar or Libravatar.", + "azure-devops": + "Fetch Azure DevOps account avatars for commit rows from the configured Azure DevOps remote.", + bitbucket: + "Fetch Bitbucket account avatars for commit rows from the configured Bitbucket remote.", + }; + +function isSourceControlAvatarProviderKind( + provider: SourceControlProviderKind, +): provider is SourceControlAvatarProviderKind { + return SOURCE_CONTROL_AVATAR_PROVIDER_KINDS.has(provider as SourceControlAvatarProviderKind); +} + const VCS_ICONS: Partial> = { git: GitIcon, jj: JujutsuIcon, @@ -359,6 +385,82 @@ function GitFetchIntervalSettings() { ); } +function SourceControlProviderOptions({ + provider, +}: { + readonly provider: SourceControlAvatarProviderKind; +}) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const providerSettings = settings.sourceControl.providers[provider]; + const defaultProviderSettings = DEFAULT_UNIFIED_SETTINGS.sourceControl.providers[provider]; + if ( + providerSettings === undefined || + defaultProviderSettings === undefined || + !SOURCE_CONTROL_AVATAR_PROVIDER_KINDS.has(provider) + ) { + return null; + } + + const showCommitAuthorAvatar = providerSettings.showCommitAuthorAvatar === true; + const canReset = showCommitAuthorAvatar !== defaultProviderSettings.showCommitAuthorAvatar; + const description = SOURCE_CONTROL_AVATAR_PROVIDER_DESCRIPTIONS[provider]; + + return ( +
+
+
+
+ Commit author avatars + + {canReset ? ( + + updateSettings({ + sourceControl: { + providers: { + ...settings.sourceControl.providers, + [provider]: { + showCommitAuthorAvatar: defaultProviderSettings.showCommitAuthorAvatar, + }, + }, + }, + }) + } + /> + ) : null} + +
+

{description}

+
+ + updateSettings({ + sourceControl: { + providers: { + ...settings.sourceControl.providers, + [provider]: { + showCommitAuthorAvatar: Boolean(checked), + }, + }, + }, + }) + } + aria-label="Show commit author avatars" + /> +
+
+ ); +} + function SourceControlSectionSkeleton({ title, headerAction, @@ -502,7 +604,11 @@ export function SourceControlSettingsPanel() { headerAction={result.versionControlSystems.length === 0 ? scanButton : null} > {result.sourceControlProviders.map((item) => ( - + + {isSourceControlAvatarProviderKind(item.kind) ? ( + + ) : undefined} + ))} ) : null} diff --git a/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx b/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx new file mode 100644 index 00000000000..09e699678cf --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlEnvironmentPanel.tsx @@ -0,0 +1,20 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; + +import { SourceControlPanelView } from "./SourceControlPanelView"; +import { useSourceControlPanelController } from "./useSourceControlPanelController"; + +export interface SourceControlEnvironmentPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly filePanelThreadRef: ScopedThreadRef | null; + readonly onThreadRefChange?: (input: { + readonly branch: string | null; + readonly worktreePath: string | null; + }) => Promise | void; +} + +export function SourceControlEnvironmentPanel(props: SourceControlEnvironmentPanelProps) { + return ; +} diff --git a/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts b/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts new file mode 100644 index 00000000000..22e91e6a6f4 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.logic.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, type VcsPanelSnapshotResult, type VcsRef } from "@t3tools/contracts"; + +import { + beginPanelFileDiffLoad, + beginPanelDetailRequest, + branchAttention, + branchHasUpstream, + branchIsCheckedOut, + branchOperationCwd, + branchSyncState, + completePanelFileDiffLoad, + drainPanelRefreshQueue, + failPanelFileDiffLoad, + formatRelativeDate, + isFederatedSourceControlTargetExpanded, + mergeChangeGroups, + namedBranchOperationCwd, + isLatestPanelDetailRequest, + panelActionError, + resolveFederatedSourceControlTargets, + runPanelActionAndReconcile, + stashIdentityKey, + vcsPanelSnapshotFingerprint, +} from "./SourceControlPanel.logic"; +import { operationPathsForFile } from "./SourceControlPanelModel"; + +const PRIMARY_ENVIRONMENT_ID = EnvironmentId.make("environment-primary"); +const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); +const DISCONNECTED_ENVIRONMENT_ID = EnvironmentId.make("environment-disconnected"); + +const baseSnapshot: VcsPanelSnapshotResult = { + status: { + isRepo: true, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "split/vscode-extension-work", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 7, + behindCount: 47, + aheadOfDefaultCount: 0, + pr: null, + }, + changeGroups: [], + worktreeChangeSets: [], + localBranches: [], + branchDetails: [], + remotes: [ + { + name: "origin", + fetchUrl: "git@example.test:fork/repo.git", + pushUrl: "git@example.test:fork/repo.git", + provider: null, + branches: [], + }, + { + name: "upstream", + fetchUrl: "git@example.test:upstream/repo.git", + pushUrl: "git@example.test:upstream/repo.git", + provider: null, + branches: [{ name: "main", fullRefName: "upstream/main", isDefaultRemoteHead: true }], + }, + ], + actionableForkBranches: [], + stashes: [], + recentCommits: [], + defaultCompareRef: "upstream/main", +}; + +function branch(input: Partial): VcsRef { + return { + name: "split/vscode-extension-work", + current: false, + isDefault: false, + worktreePath: null, + ...input, + }; +} + +describe("SourceControlPanel branch sync logic", () => { + it("publishes a local branch whose configured upstream is only its comparison base", () => { + const localBranch = branch({ + current: true, + upstreamName: "upstream/main", + aheadCount: 7, + behindCount: 47, + }); + + expect(branchHasUpstream(localBranch, baseSnapshot)).toBe(false); + expect(branchSyncState(localBranch, baseSnapshot)).toBe("publish"); + expect(branchAttention(localBranch, baseSnapshot)).toBe("unpushed"); + }); + + it("treats a same-name remote tracking branch as the sync upstream", () => { + const localBranch = branch({ + name: "split/subagent-threading-work", + upstreamName: "origin/split/subagent-threading-work", + aheadCount: 0, + behindCount: 3, + }); + + expect(branchHasUpstream(localBranch, baseSnapshot)).toBe(true); + expect(branchSyncState(localBranch, baseSnapshot)).toBe("pull"); + expect(branchAttention(localBranch, baseSnapshot)).toBe("behind"); + }); + + it("targets the branch worktree cwd for branch operations when present", () => { + expect( + branchOperationCwd( + branch({ + worktreePath: "/repo.worktrees/feature", + }), + "/repo", + ), + ).toBe("/repo.worktrees/feature"); + expect(branchOperationCwd(branch({}), "/repo")).toBe("/repo"); + }); + + it("resolves named branch actions to the checkout that owns the branch", () => { + const branches = [ + branch({ name: "main", current: true, worktreePath: "/repo" }), + branch({ name: "feature", worktreePath: "/repo.worktrees/feature" }), + ]; + + expect(namedBranchOperationCwd(branches, "feature", "/repo")).toBe("/repo.worktrees/feature"); + expect(namedBranchOperationCwd(branches, "missing", "/repo")).toBe("/repo"); + }); + + it("treats current and sibling-worktree branches as checked out", () => { + expect(branchIsCheckedOut(branch({ current: true }))).toBe(true); + expect(branchIsCheckedOut(branch({ worktreePath: "/repo.worktrees/feature" }))).toBe(true); + expect(branchIsCheckedOut(branch({}))).toBe(false); + expect(branchIsCheckedOut(undefined)).toBe(false); + }); +}); + +describe("SourceControlPanel working-tree presentation logic", () => { + it("sums staged and unstaged stats for the same path", () => { + expect( + mergeChangeGroups([ + { + kind: "staged", + files: [ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 2, + deletions: 1, + }, + ], + }, + { + kind: "unstaged", + files: [ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 3, + deletions: 4, + }, + ], + }, + ]), + ).toEqual([ + { + path: "src/file.ts", + originalPath: null, + status: "modified", + insertions: 5, + deletions: 5, + hasStagedChanges: true, + hasUnstagedChanges: true, + hasConflicts: false, + }, + ]); + }); + + it("preserves status precedence and conflict flags when merging paths", () => { + expect( + mergeChangeGroups([ + { + kind: "staged", + files: [ + { + path: "src/cafe.ts", + originalPath: null, + status: "modified", + insertions: 1, + deletions: 0, + }, + ], + }, + { + kind: "conflicts", + files: [ + { + path: "src/cafe.ts", + originalPath: null, + status: "conflicted", + insertions: 0, + deletions: 2, + }, + { + path: "src/áudio.ts", + originalPath: null, + status: "added", + insertions: 3, + deletions: 0, + }, + ], + }, + ]), + ).toEqual([ + { + path: "src/áudio.ts", + originalPath: null, + status: "added", + insertions: 3, + deletions: 0, + hasStagedChanges: false, + hasUnstagedChanges: false, + hasConflicts: true, + }, + { + path: "src/cafe.ts", + originalPath: null, + status: "conflicted", + insertions: 1, + deletions: 2, + hasStagedChanges: true, + hasUnstagedChanges: false, + hasConflicts: true, + }, + ]); + }); + + it("formats future timestamps as just now", () => { + const now = Date.parse("2026-06-20T12:00:00.000Z"); + const then = new Date(now + 5 * 60 * 1000).toISOString(); + + expect(formatRelativeDate(then, now)).toBe("just now"); + }); + + it("formats late-month dates before the one-year threshold as months", () => { + const now = Date.parse("2026-06-20T12:00:00.000Z"); + const then = new Date(now - 360 * 24 * 60 * 60 * 1000).toISOString(); + + expect(formatRelativeDate(then, now)).toBe("11 months ago"); + }); + + it("includes the original path for renames but not copies", () => { + expect( + operationPathsForFile({ + path: "src/renamed.ts", + originalPath: "src/original.ts", + status: "renamed", + }), + ).toEqual(["src/renamed.ts", "src/original.ts"]); + expect( + operationPathsForFile({ + path: "src/copied.ts", + originalPath: "src/original.ts", + status: "copied", + }), + ).toEqual(["src/copied.ts"]); + }); +}); + +describe("SourceControlPanel stash identity", () => { + it("uses the stash commit hash instead of the positional ref when available", () => { + expect( + stashIdentityKey({ + refName: "stash@{0}", + sha: "abc123", + createdAt: "2026-06-30T13:00:00Z", + message: "WIP on main: abc123 change", + }), + ).toBe("sha:abc123"); + }); + + it("falls back to the positional ref for stashes without a hash", () => { + expect( + stashIdentityKey({ + refName: "stash@{0}", + sha: null, + createdAt: null, + message: "stash@{0}", + }), + ).toBe("ref:stash@{0}"); + }); +}); + +describe("SourceControlPanel refresh stability logic", () => { + it("reconciles repository state after a failed panel action", async () => { + const calls: string[] = []; + const failure = new Error("merge produced conflicts"); + + const result = await runPanelActionAndReconcile({ + action: async () => { + calls.push("action"); + throw failure; + }, + reconcile: async () => { + calls.push("reconcile"); + }, + }); + + expect(calls).toEqual(["action", "reconcile"]); + expect(result).toEqual({ status: "failure", error: failure }); + }); + + it("preserves the mutation error when reconciliation also fails", () => { + const mutationError = new Error("merge produced conflicts"); + const reconcileError = new Error("snapshot refresh failed"); + + expect(panelActionError({ status: "failure", error: mutationError }, reconcileError)).toBe( + mutationError, + ); + expect(panelActionError({ status: "success" }, reconcileError)).toBe(reconcileError); + }); + + it("rejects late branch-detail responses for the same rendered surface", () => { + const requests = new Map(); + const first = beginPanelDetailRequest(requests, "branch:feature"); + const second = beginPanelDetailRequest(requests, "branch:feature"); + + expect(isLatestPanelDetailRequest(requests, "branch:feature", first)).toBe(false); + expect(isLatestPanelDetailRequest(requests, "branch:feature", second)).toBe(true); + }); + + it("drains a queued refresh after the active refresh fails", async () => { + let queuedMode: "full" | "working-tree" | null = null; + const calls: string[] = []; + const errors: unknown[] = []; + + await drainPanelRefreshQueue({ + initialMode: "full", + clearQueuedMode: () => { + queuedMode = null; + }, + readQueuedMode: () => queuedMode, + run: async (mode) => { + calls.push(mode); + if (calls.length !== 1) return; + queuedMode = "working-tree"; + throw new Error("interrupted"); + }, + onError: (error) => { + errors.push(error); + }, + }); + + expect(calls).toEqual(["full", "working-tree"]); + expect(errors).toEqual([expect.objectContaining({ message: "interrupted" })]); + }); + + it("fingerprints snapshots with their cwd so equal snapshots from different repos are distinct", () => { + expect(vcsPanelSnapshotFingerprint("/repo/one", baseSnapshot)).toBe( + vcsPanelSnapshotFingerprint("/repo/one", { ...baseSnapshot }), + ); + expect(vcsPanelSnapshotFingerprint("/repo/one", baseSnapshot)).not.toBe( + vcsPanelSnapshotFingerprint("/repo/two", baseSnapshot), + ); + }); + + it("keeps a loaded diff mounted while a refresh revalidates it", () => { + const loaded = { status: "loaded", patch: "same patch" } as const; + + expect(beginPanelFileDiffLoad(loaded, { preserveLoaded: true })).toBe(loaded); + expect(completePanelFileDiffLoad(loaded, "same patch")).toBe(loaded); + expect(failPanelFileDiffLoad(loaded, "failed", { preserveLoaded: true })).toBe(loaded); + }); + + it("updates preserved loaded diffs only when the refreshed patch changes", () => { + const loaded = { status: "loaded", patch: "old patch" } as const; + + expect(completePanelFileDiffLoad(loaded, "new patch")).toEqual({ + status: "loaded", + patch: "new patch", + }); + expect(beginPanelFileDiffLoad(loaded)).toEqual({ status: "loading" }); + expect(failPanelFileDiffLoad(loaded, "failed")).toEqual({ + status: "error", + message: "failed", + }); + }); +}); + +describe("SourceControlPanel environment federation", () => { + it("always expands the current environment and collapses other environments by default", () => { + expect( + isFederatedSourceControlTargetExpanded( + { active: true, environmentId: PRIMARY_ENVIRONMENT_ID }, + new Set(), + ), + ).toBe(true); + expect( + isFederatedSourceControlTargetExpanded( + { active: false, environmentId: REMOTE_ENVIRONMENT_ID }, + new Set(), + ), + ).toBe(false); + expect( + isFederatedSourceControlTargetExpanded( + { active: false, environmentId: REMOTE_ENVIRONMENT_ID }, + new Set([REMOTE_ENVIRONMENT_ID]), + ), + ).toBe(true); + }); + + it("omits disconnected environments and keeps the active checkout first", () => { + expect( + resolveFederatedSourceControlTargets({ + activeEnvironmentId: REMOTE_ENVIRONMENT_ID, + activeCwd: "/remote/repo.worktrees/active", + activeWorktreePath: "/remote/repo.worktrees/active", + candidates: [ + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + cwd: "/local/repo", + connected: true, + }, + { + environmentId: DISCONNECTED_ENVIRONMENT_ID, + label: "Offline server", + isPrimary: false, + cwd: "/offline/repo", + connected: false, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + cwd: "/remote/repo", + connected: true, + }, + ], + }), + ).toEqual([ + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + cwd: "/remote/repo.worktrees/active", + connected: true, + active: true, + worktreePath: "/remote/repo.worktrees/active", + }, + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + cwd: "/local/repo", + connected: true, + active: false, + worktreePath: null, + }, + ]); + }); + + it("deduplicates multiple physical projects in the same connected environment", () => { + expect( + resolveFederatedSourceControlTargets({ + activeEnvironmentId: PRIMARY_ENVIRONMENT_ID, + activeCwd: "/local/repo", + activeWorktreePath: null, + candidates: [ + { + environmentId: PRIMARY_ENVIRONMENT_ID, + label: "This device", + isPrimary: true, + cwd: "/local/repo", + connected: true, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + cwd: "/remote/repo", + connected: true, + }, + { + environmentId: REMOTE_ENVIRONMENT_ID, + label: "Build server", + isPrimary: false, + cwd: "/remote/repo/packages/web", + connected: true, + }, + ], + }).map((target) => target.cwd), + ).toEqual(["/local/repo", "/remote/repo"]); + }); +}); diff --git a/apps/web/src/components/source-control/SourceControlPanel.logic.ts b/apps/web/src/components/source-control/SourceControlPanel.logic.ts new file mode 100644 index 00000000000..62a802187d7 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.logic.ts @@ -0,0 +1,215 @@ +import type { + EnvironmentId, + VcsPanelChangeGroup, + VcsPanelStash, + VcsPanelSnapshotResult, + VcsRef, +} from "@t3tools/contracts"; +import { + mergePanelChangeGroups, + panelBranchAttention as branchAttention, + panelBranchHasUpstream as branchHasUpstream, + panelBranchOperationCwd as branchOperationCwd, + panelBranchSyncCounts as branchSyncCounts, + panelBranchSyncState as branchSyncState, + type BranchAttentionKind, + type BranchSyncState, + type PanelChangedFile, +} from "@t3tools/shared/sourceControl"; + +export { + branchAttention, + branchHasUpstream, + branchOperationCwd, + branchSyncCounts, + branchSyncState, +}; +export type { BranchSyncState, PanelChangedFile }; + +export type AttentionKind = BranchAttentionKind; + +export type PanelRefreshMode = "full" | "working-tree"; + +export interface SourceControlEnvironmentCandidate { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly isPrimary: boolean; + readonly cwd: string; + readonly connected: boolean; +} + +export interface FederatedSourceControlTarget extends SourceControlEnvironmentCandidate { + readonly active: boolean; + readonly worktreePath: string | null; +} + +export function isFederatedSourceControlTargetExpanded( + target: Pick, + expandedEnvironmentIds: ReadonlySet, +): boolean { + return target.active || expandedEnvironmentIds.has(target.environmentId); +} + +export function resolveFederatedSourceControlTargets(input: { + readonly activeEnvironmentId: EnvironmentId; + readonly activeCwd: string; + readonly activeWorktreePath: string | null; + readonly candidates: readonly SourceControlEnvironmentCandidate[]; +}): FederatedSourceControlTarget[] { + const targetByEnvironmentId = new Map(); + for (const candidate of input.candidates) { + if (!candidate.connected || targetByEnvironmentId.has(candidate.environmentId)) continue; + const active = candidate.environmentId === input.activeEnvironmentId; + targetByEnvironmentId.set(candidate.environmentId, { + ...candidate, + active, + cwd: active ? input.activeCwd : candidate.cwd, + worktreePath: active ? input.activeWorktreePath : null, + }); + } + + return [...targetByEnvironmentId.values()].toSorted((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + if (left.isPrimary !== right.isPrimary) return left.isPrimary ? -1 : 1; + return left.label.localeCompare(right.label); + }); +} + +export async function drainPanelRefreshQueue(options: { + readonly initialMode: PanelRefreshMode; + readonly clearQueuedMode: () => void; + readonly readQueuedMode: () => PanelRefreshMode | null; + readonly run: (mode: PanelRefreshMode) => Promise; + readonly onError: (error: unknown) => void; +}): Promise { + let mode = options.initialMode; + while (true) { + options.clearQueuedMode(); + try { + await options.run(mode); + } catch (error) { + options.onError(error); + } + const queuedMode = options.readQueuedMode(); + if (queuedMode === null) return; + mode = queuedMode; + } +} + +export type PanelActionResult = + | { readonly status: "success" } + | { readonly status: "failure"; readonly error: unknown }; + +export function panelActionError( + result: PanelActionResult | null, + reconcileError: unknown, +): unknown { + return result?.status === "failure" ? result.error : reconcileError; +} + +export async function runPanelActionAndReconcile(options: { + readonly action: () => Promise; + readonly reconcile: () => Promise; +}): Promise { + const result = await options.action().then( + () => ({ status: "success" }) as const, + (error: unknown) => ({ status: "failure", error }) as const, + ); + await options.reconcile(); + return result; +} + +export function beginPanelDetailRequest(requestsByKey: Map, key: string): number { + const requestId = (requestsByKey.get(key) ?? 0) + 1; + requestsByKey.set(key, requestId); + return requestId; +} + +export function isLatestPanelDetailRequest( + requestsByKey: ReadonlyMap, + key: string, + requestId: number, +): boolean { + return requestsByKey.get(key) === requestId; +} + +export function branchIsCheckedOut(branch: VcsRef | undefined): boolean { + return branch?.current === true || branch?.worktreePath != null; +} + +export function namedBranchOperationCwd( + branches: readonly VcsRef[], + branchName: string, + fallbackCwd: string, +): string { + const branch = branches.find((candidate) => candidate.name === branchName); + return branch ? branchOperationCwd(branch, fallbackCwd) : fallbackCwd; +} + +export type PanelFileDiffLoadState = + | { readonly status: "loading" } + | { readonly status: "loaded"; readonly patch: string } + | { readonly status: "error"; readonly message: string }; + +export function vcsPanelSnapshotFingerprint(cwd: string, snapshot: VcsPanelSnapshotResult): string { + return `${cwd}\0${JSON.stringify(snapshot)}`; +} + +export function stashIdentityKey(stash: VcsPanelStash): string { + return stash.sha ? `sha:${stash.sha}` : `ref:${stash.refName}`; +} + +export function beginPanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + options: { readonly preserveLoaded?: boolean } = {}, +): PanelFileDiffLoadState { + if (options.preserveLoaded && current?.status === "loaded") return current; + return { status: "loading" }; +} + +export function completePanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + patch: string, +): PanelFileDiffLoadState { + if (current?.status === "loaded" && current.patch === patch) return current; + return { status: "loaded", patch }; +} + +export function failPanelFileDiffLoad( + current: PanelFileDiffLoadState | undefined, + message: string, + options: { readonly preserveLoaded?: boolean } = {}, +): PanelFileDiffLoadState { + if (options.preserveLoaded && current?.status === "loaded") return current; + return { status: "error", message }; +} + +export function mergeChangeGroups(groups: readonly VcsPanelChangeGroup[]): PanelChangedFile[] { + return mergePanelChangeGroups(groups); +} + +export function formatRelativeDate( + value: string | null | undefined, + now = Date.now(), +): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + const elapsedMs = now - time; + if (elapsedMs <= 0) return "just now"; + 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.min(11, Math.floor(days / 30)); + if (days < 365) return `${months} month${months === 1 ? "" : "s"} ago`; + const years = Math.floor(days / 365); + return `${years} year${years === 1 ? "" : "s"} ago`; +} 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 00000000000..30db27ea0de --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanel.tsx @@ -0,0 +1,138 @@ +import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { MonitorIcon, ServerIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { + isFederatedSourceControlTargetExpanded, + resolveFederatedSourceControlTargets, + type SourceControlEnvironmentCandidate, +} from "./SourceControlPanel.logic"; +import { + SourceControlEnvironmentPanel, + type SourceControlEnvironmentPanelProps, +} from "./SourceControlEnvironmentPanel"; + +interface SourceControlPanelProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; + readonly environments: readonly SourceControlEnvironmentCandidate[]; + readonly onThreadRefChange?: SourceControlEnvironmentPanelProps["onThreadRefChange"]; +} + +export function SourceControlPanel({ + cwd, + environmentId, + environments, + onThreadRefChange, + threadId, + worktreePath, +}: SourceControlPanelProps) { + const [expandedEnvironmentIds, setExpandedEnvironmentIds] = useState>( + () => new Set(), + ); + const targets = useMemo( + () => + resolveFederatedSourceControlTargets({ + activeEnvironmentId: environmentId, + activeCwd: cwd, + activeWorktreePath: worktreePath, + candidates: environments, + }), + [cwd, environmentId, environments, worktreePath], + ); + const activeThreadRef = useMemo( + () => ({ environmentId, threadId }), + [environmentId, threadId], + ); + + if (targets.length === 0) return null; + + const showEnvironmentHeaders = targets.length > 1 || targets.some((target) => !target.isPrimary); + if (!showEnvironmentHeaders) { + const target = targets[0]!; + return ( + + ); + } + + return ( +
+ {targets.map((target) => { + const EnvironmentIcon = target.isPrimary ? MonitorIcon : ServerIcon; + const expanded = isFederatedSourceControlTargetExpanded(target, expandedEnvironmentIds); + const environmentHeaderContents = ( + <> + + + {target.label} + + + {target.cwd} + + + ); + return ( +
+ {target.active ? ( +
+ {environmentHeaderContents} +
+ ) : ( + + )} + {expanded ? ( + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelBranches.tsx b/apps/web/src/components/source-control/SourceControlPanelBranches.tsx new file mode 100644 index 00000000000..2e8d5e2df7a --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelBranches.tsx @@ -0,0 +1,877 @@ +import type { + EnvironmentId, + ThreadId, + VcsPanelBranchDetails, + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelSnapshotResult, + VcsPanelStashDetails, + VcsPanelWorkingTreeFileEnrichmentResult, + VcsRef, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + ChevronDown, + ChevronRight, + GitBranch, + GitBranchPlus, + GitCommit, + GitMerge, + GitPullRequestArrow, + RefreshCw, + RotateCcw, + Trash2, + Undo2, +} from "lucide-react"; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + ReactNode, +} from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipTrigger } from "../ui/tooltip"; +import { + type AttentionKind, + type BranchSyncState, + branchAttention, + branchHasUpstream, + branchIsCheckedOut, + branchSyncCounts, + branchSyncState, + drainPanelRefreshQueue, + namedBranchOperationCwd, + type PanelChangedFile, + stashIdentityKey, +} from "./SourceControlPanel.logic"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localOnlyBranches, + operationPathsForFile, + shouldEnrichWorkingTreeFile, + stashBranchName, + sumFiles, + treeKey, + worktreeChangeSetId, + commitCountLabel, + contextMenuSeparator, + type FileDiffSource, + type WorkingTreeChangeSetView, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncActionIcon, + BranchSyncLabels, + BranchTooltip, + CommitTooltip, + CompactBadge, + IconButton, + RefLabels, + RowActions, + StatLabels, + WorkingTreeTooltip, + branchSyncActionLabel, +} from "./SourceControlPanelPrimitives"; +import type { ReadySourceControlPanelController } from "./useSourceControlPanelController"; +import { Download, Upload } from "lucide-react"; +import { TooltipCardPopup } from "../ui/tooltip"; +import { formatRelativeDate } from "./SourceControlPanel.logic"; +import { AuthorAvatar, SyncedIcon } from "./SourceControlPanelPrimitives"; +import { FileChangeList, FileChangeSummary, LoadMoreCommitsButton } from "./SourceControlPanelRows"; + +export function makeSourceControlPanelBranchRenderers( + controller: ReadySourceControlPanelController, +) { + const { + api, + branchDetailsByRef, + checkoutCommitDetached, + copyText, + createBranchFromCommit, + cwd, + deleteBranch, + expandedTree, + fileDiffKey, + fileDiffListProps, + isActionRunning, + isTreeExpanded, + loadMoreBranchCommits, + loadingBranchDetails, + mergeBranchIntoCurrent, + openContextMenu, + openFileChangeContextMenu, + openInVsCode, + rebaseCurrentOnto, + resolvedTheme, + revertCommit, + runAction, + runBranchSync, + setCompareBaseDialogTarget, + setCompareBaseQuery, + snapshot, + switchRef, + syncBranch, + toggleBranchTree, + toggleBranchTreeFromKeyboard, + toggleTree, + toggleTreeFromKeyboard, + undoCommit, + } = controller; + 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} + remote.name)} + /> + + {relativeDate ? ( + {relativeDate} + ) : null} + + {canUndoCommit && options.undoBranchName ? ( + { + if (options.undoBranchName) undoCommit(options.undoBranchName, commit); + }} + > + + + ) : null} + revertCommit(commit)} + > + + + rebaseCurrentOnto(commit.sha)} + > + + + checkoutCommitDetached(commit)} + > + + + createBranchFromCommit(commit)} + > + + + +
+ } + /> + + remote.name)} + /> + + + {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(detailsKey); + 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", detailsKey)} + /> +
+ ), + }) + : null} + {behindTotal > 0 + ? renderBranchSubsection({ + details, + id: "behind", + title: `${behindTotal} Behind`, + count: null, + icon: , + children: ( +
+ {details.behindCommits.map(renderBranchCommit)} + + void loadMoreBranchCommits(branch, details, "behind", detailsKey) + } + /> +
+ ), + }) + : 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", detailsKey)} + /> +
+ ), + })} + {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 checkedOut = branchIsCheckedOut(branch); + const switchDisabled = checkedOut || isActionRunning(switchKey); + const syncDisabled = isActionRunning(syncKey) || isActionRunning(fetchKey); + const deleteDisabled = checkedOut || 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 checkedOut = hasLocalBranch && branchIsCheckedOut(branch); + const switchDisabled = checkedOut || isActionRunning(switchKey); + const syncLabel = hasLocalBranch ? branchSyncActionLabel(syncState) : "Fetch branch"; + const syncDisabled = hasLocalBranch + ? isActionRunning(syncKey) || isActionRunning(fetchKey) + : isActionRunning(fetchKey); + const deleteDisabled = checkedOut || 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} + + ); + }; + + return { branchRow, remoteBranchRow, renderCommit }; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelCache.ts b/apps/web/src/components/source-control/SourceControlPanelCache.ts new file mode 100644 index 00000000000..e3d0107c6f2 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelCache.ts @@ -0,0 +1,108 @@ +import type { + EnvironmentId, + ThreadId, + VcsPanelBranchDetails, + VcsPanelFileChange, + VcsPanelSnapshotResult, + VcsPanelStashDetails, +} from "@t3tools/contracts"; + +import type { PanelFileDiffLoadState } from "./SourceControlPanel.logic"; + +export type SourceControlSectionKey = "work" | "remotes"; + +export interface CachedSourceControlPanelState { + readonly snapshot: VcsPanelSnapshotResult | null; + readonly snapshotFingerprint: string | null; + readonly collapsed: ReadonlySet; + readonly sectionWeights: Record; + readonly expandedTree: ReadonlySet; + readonly collapsedDefaultTree: ReadonlySet; + readonly branchDetailsByRef: ReadonlyMap; + readonly compareBaseOverrides: ReadonlyMap; + readonly stashDetailsByKey: ReadonlyMap; + readonly expandedFileDiffs: ReadonlySet; + readonly fileDiffsByKey: ReadonlyMap; + readonly enrichedWorkingTreeFilesByPath: ReadonlyMap; + readonly hiddenWorkingTreePaths: ReadonlySet; + readonly selectedChangePaths: ReadonlySet; + readonly selectedWorktreeChangePaths: ReadonlyMap>; +} + +const PANEL_STATE_CACHE_LIMIT = 24; +const sourceControlPanelStateCache = new Map(); + +export function sourceControlPanelStateCacheKey(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + readonly worktreePath: string | null; +}): string { + return `${input.environmentId}:${input.threadId}:${input.cwd}:${input.worktreePath ?? ""}`; +} + +function cloneReadonlySet(value: ReadonlySet): ReadonlySet { + return new Set(value); +} + +function cloneStringSetMap( + value: ReadonlyMap>, +): ReadonlyMap> { + return new Map([...value].map(([key, set]) => [key, new Set(set)])); +} + +function cacheableFileDiffs( + value: ReadonlyMap, +): ReadonlyMap { + return new Map([...value].filter(([, state]) => state.status !== "loading")); +} + +function cacheableExpandedFileDiffs( + expanded: ReadonlySet, + fileDiffsByKey: ReadonlyMap, +): ReadonlySet { + return new Set([...expanded].filter((key) => fileDiffsByKey.has(key))); +} + +function cloneCachedSourceControlPanelState( + value: CachedSourceControlPanelState, +): CachedSourceControlPanelState { + const fileDiffsByKey = cacheableFileDiffs(value.fileDiffsByKey); + return { + snapshot: value.snapshot, + snapshotFingerprint: value.snapshotFingerprint, + collapsed: cloneReadonlySet(value.collapsed), + sectionWeights: { ...value.sectionWeights }, + expandedTree: cloneReadonlySet(value.expandedTree), + collapsedDefaultTree: cloneReadonlySet(value.collapsedDefaultTree), + branchDetailsByRef: new Map(value.branchDetailsByRef), + compareBaseOverrides: new Map(value.compareBaseOverrides), + stashDetailsByKey: new Map(value.stashDetailsByKey), + expandedFileDiffs: cacheableExpandedFileDiffs(value.expandedFileDiffs, fileDiffsByKey), + fileDiffsByKey, + enrichedWorkingTreeFilesByPath: new Map(value.enrichedWorkingTreeFilesByPath), + hiddenWorkingTreePaths: cloneReadonlySet(value.hiddenWorkingTreePaths), + selectedChangePaths: cloneReadonlySet(value.selectedChangePaths), + selectedWorktreeChangePaths: cloneStringSetMap(value.selectedWorktreeChangePaths), + }; +} + +export function readCachedSourceControlPanelState( + key: string, +): CachedSourceControlPanelState | null { + const cached = sourceControlPanelStateCache.get(key); + return cached ? cloneCachedSourceControlPanelState(cached) : null; +} + +export function writeCachedSourceControlPanelState( + key: string, + value: CachedSourceControlPanelState, +): void { + sourceControlPanelStateCache.delete(key); + sourceControlPanelStateCache.set(key, cloneCachedSourceControlPanelState(value)); + while (sourceControlPanelStateCache.size > PANEL_STATE_CACHE_LIMIT) { + const oldestKey = sourceControlPanelStateCache.keys().next().value; + if (oldestKey === undefined) break; + sourceControlPanelStateCache.delete(oldestKey); + } +} diff --git a/apps/web/src/components/source-control/SourceControlPanelModel.ts b/apps/web/src/components/source-control/SourceControlPanelModel.ts new file mode 100644 index 00000000000..3ae246e4098 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelModel.ts @@ -0,0 +1,323 @@ +import type { + ContextMenuItem, + VcsPanelBranchDetails, + VcsPanelChangeGroup, + VcsPanelFileChange, + VcsPanelFileDiffInput, + VcsPanelRemote, + VcsPanelSnapshotResult, + VcsPanelStash, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import type { MouseEvent as ReactMouseEvent } from "react"; + +import type { + AttentionKind, + PanelChangedFile, + PanelFileDiffLoadState, +} from "./SourceControlPanel.logic"; +import { branchHasUpstream, stashIdentityKey } from "./SourceControlPanel.logic"; +import type { SourceControlSectionKey } from "./SourceControlPanelCache"; + +export type FileDiffSource = NonNullable; +export type FileDiffLoadState = PanelFileDiffLoadState; + +export interface WorkingTreeChangeSetView { + readonly id: string; + readonly label: string; + readonly cwd: string; + readonly branchName: string | null; + readonly worktreePath: string | null; + readonly current: boolean; + readonly changeGroups: readonly VcsPanelChangeGroup[]; + readonly files: readonly PanelChangedFile[]; + readonly selectedPaths: ReadonlySet; + readonly activity: number; +} + +export type SectionKey = SourceControlSectionKey; + +export const SECTION_ORDER: readonly SectionKey[] = ["work", "remotes"]; +export const SECTION_TITLES: Record = { + work: "Actionable", + remotes: "Remotes", +}; +export const DEFAULT_SECTION_WEIGHTS: Record = { + work: 3, + remotes: 1.4, +}; +export const COLLAPSED_SECTION_HEIGHT = 32; +export const MIN_SECTION_WEIGHT = 0.35; +export const COMMIT_PAGE_SIZE = 10; +export const WORKING_FILE_PREFETCH_MARGIN = 600; + +const ENRICHMENT_KEY_SEPARATOR = "\0"; +const readableDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Source control action failed."; +} + +export function applyWorkingTreeFileEnrichment( + groups: readonly VcsPanelChangeGroup[], + targetCwd: string, + 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) => { + const key = enrichmentFileKey(targetCwd, file.path); + if (hiddenPaths.has(key)) return []; + const enrichedFile = enrichedFilesByPath.get(key) ?? file; + seenPaths.add(enrichedFile.path); + return [enrichedFile]; + }); + for (const [key, enrichedFile] of enrichedFilesByPath) { + const parsed = splitEnrichmentFileKey(key); + if (parsed.cwd !== targetCwd) continue; + if (seenPaths.has(enrichedFile.path) || hiddenPaths.has(key)) continue; + files.push(enrichedFile); + } + return { + ...group, + files: files.toSorted((left, right) => left.path.localeCompare(right.path)), + }; + }); +} + +export function shouldEnrichWorkingTreeFile(file: PanelChangedFile): boolean { + return file.hasUnstagedChanges && (file.status === "untracked" || file.status === "deleted"); +} + +export function enrichmentFileKey(cwd: string, path: string): string { + return `${cwd}${ENRICHMENT_KEY_SEPARATOR}${path}`; +} + +export function splitEnrichmentFileKey(key: string): { + readonly cwd: string; + readonly path: string; +} { + const separatorIndex = key.indexOf(ENRICHMENT_KEY_SEPARATOR); + if (separatorIndex < 0) return { cwd: "", path: key }; + return { + cwd: key.slice(0, separatorIndex), + path: key.slice(separatorIndex + ENRICHMENT_KEY_SEPARATOR.length), + }; +} + +export function isActionForced(event: ReactMouseEvent): boolean { + return event.shiftKey; +} + +export function shouldFetchBeforePull(event: ReactMouseEvent): boolean { + return event.altKey; +} + +export function commitUndoActionKey(branchName: string, sha?: string): string { + return sha ? `commit-undo:${branchName}:${sha}` : `branch-undo-latest:${branchName}`; +} + +export function treeKey(kind: string, id: string): string { + return `${kind}:${id}`; +} + +export function formatReadableDate(value: string | null | undefined): string | null { + if (!value) return null; + const time = Date.parse(value); + if (!Number.isFinite(time)) return null; + return readableDateFormatter.format(new Date(time)); +} + +export function sumFiles(files: readonly VcsPanelFileChange[]) { + return files.reduce( + (total, file) => ({ + insertions: total.insertions + file.insertions, + deletions: total.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export 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; +} + +export function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths.filter((path) => path.length > 0))]; +} + +export function operationPathsForFile( + file: Pick, +): string[] { + return uniquePaths( + file.status === "renamed" && file.originalPath ? [file.path, file.originalPath] : [file.path], + ); +} + +export function worktreeChangeSetId( + changeSet: Pick, +): string { + return `worktree:${changeSet.worktreePath}`; +} + +export function changeSetAttention(files: readonly PanelChangedFile[]): AttentionKind { + return files.some((file) => file.hasConflicts) + ? "conflicts" + : files.length > 0 + ? "dirty" + : "stale"; +} + +export function commitCountLabel(count: number): string { + return count === 1 ? "1 commit" : `${count} commits`; +} + +export function stashBranchName(stash: VcsPanelStash): string | null { + return /^(?:WIP\s+)?on\s+([^:]+):/i.exec(stash.message)?.[1]?.trim() ?? null; +} + +export function contextMenuSeparator(id: T): ContextMenuItem { + return { id, label: "", separator: true }; +} + +export 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; +} + +export 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; +} + +export 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, + }; +} + +export 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 + ); +} + +export function localOnlyBranches(snapshot: VcsPanelSnapshotResult): VcsRef[] { + return snapshot.localBranches + .filter((branch) => !branchHasUpstream(branch, snapshot)) + .toSorted((left, right) => branchActivityTimestamp(right) - branchActivityTimestamp(left)); +} + +export 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)); +} + +export interface ExpandedBranchRequest { + readonly branch: VcsRef; + readonly detailsKey: string; + readonly compareBaseRef?: string; +} + +export 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]; +} + +export interface ExpandedStashRequest { + readonly stashRef: string; + readonly detailsKey: string; +} + +export function expandedStashesForSnapshot( + snapshot: VcsPanelSnapshotResult, + expanded: ReadonlySet, +): ExpandedStashRequest[] { + return snapshot.stashes + .filter((stash) => expanded.has(treeKey("stash", stashIdentityKey(stash)))) + .map((stash) => ({ stashRef: stash.refName, detailsKey: stashIdentityKey(stash) })); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx b/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx new file mode 100644 index 00000000000..d5deaa9044b --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelPrimitives.tsx @@ -0,0 +1,668 @@ +import type { + VcsPanelCommitSummary, + VcsPanelFileChange, + VcsPanelRemote, + VcsPanelStash, + VcsRef, +} from "@t3tools/contracts"; +import { + AlertTriangle, + ChevronDown, + ChevronRight, + Download, + FileText, + GitBranch, + GitCommit, + GitCompare, + LoaderCircle, + RefreshCw, + Tag, + Target, + Upload, +} from "lucide-react"; +import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { useCallback, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { VisualStudioCode } from "../Icons"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipCardPopup, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + type AttentionKind, + type BranchSyncState, + formatRelativeDate, +} from "./SourceControlPanel.logic"; +import { formatReadableDate, type WorkingTreeChangeSetView } from "./SourceControlPanelModel"; + +export 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} + + ); +} + +export 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} + + ); +} + +export 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"; + } +} + +export function BranchSyncActionIcon({ state }: { readonly state: BranchSyncState }) { + switch (state) { + case "publish": + case "push": + return ; + case "pull": + return ; + case "diverged": + return ; + case "fetch": + return ; + } +} + +export const ATTENTION_RANK: Record = { + conflicts: 0, + diverged: 1, + behind: 2, + unpushed: 3, + dirty: 4, + stale: 5, +}; + +export function stashActivityTimestamp(stash: VcsPanelStash): number { + if (!stash.createdAt) return 0; + const time = Date.parse(stash.createdAt); + return Number.isFinite(time) ? time : 0; +} + +export 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 ; + } +} + +export 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 setFailed(true)} + /> + ); +} + +type DisplayHeadRef = + | { readonly kind: "local"; readonly name: string; readonly synced: boolean } + | { readonly kind: "remote"; readonly name: string }; + +function displayHeadRefs( + headRefs: readonly string[], + remoteNames: readonly string[] = [], +): DisplayHeadRef[] { + const remoteNameSet = new Set(remoteNames); + const remoteRefParts = ( + ref: string, + ): { readonly remoteName: string; readonly branchName: string } | null => { + const slashIndex = ref.indexOf("/"); + if (slashIndex <= 0) return null; + const remoteName = ref.slice(0, slashIndex); + if (!remoteNameSet.has(remoteName)) return null; + const branchName = ref.slice(slashIndex + 1); + if (branchName.length === 0 || branchName === "HEAD") return null; + return { remoteName, branchName }; + }; + const localRefs = new Set(headRefs.filter((ref) => remoteRefParts(ref) === null)); + const remoteByBranch = new Map(); + for (const ref of headRefs) { + const parsed = remoteRefParts(ref); + if (parsed) remoteByBranch.set(parsed.branchName, ref); + } + const refs: DisplayHeadRef[] = [...localRefs] + .toSorted((left, right) => left.localeCompare(right)) + .map((name) => ({ kind: "local", name, synced: remoteByBranch.has(name) })); + for (const branchName of [...remoteByBranch.keys()].toSorted((left, right) => + left.localeCompare(right), + )) { + if (!localRefs.has(branchName)) refs.push({ kind: "remote", name: branchName }); + } + return refs; +} + +export function SyncedIcon({ className }: { readonly className?: string }) { + return ; +} + +export function CompactBadge({ children }: { readonly children: ReactNode }) { + return ( + + {children} + + ); +} + +export function RefLabels({ + commit, + remoteNames, +}: { + readonly commit: VcsPanelCommitSummary; + readonly remoteNames?: readonly string[] | undefined; +}) { + const headRefs = displayHeadRefs(commit.headRefs, remoteNames); + 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 sumFiles(files: readonly VcsPanelFileChange[]) { + return files.reduce( + (sum, file) => ({ + insertions: sum.insertions + file.insertions, + deletions: sum.deletions + file.deletions, + }), + { insertions: 0, deletions: 0 }, + ); +} + +export function CommitTooltip({ + commit, + remoteNames, +}: { + readonly commit: VcsPanelCommitSummary; + readonly remoteNames?: readonly string[] | undefined; +}) { + const relativeDate = formatRelativeDate(commit.authoredAt); + const readableDate = formatReadableDate(commit.authoredAt); + const stats = sumFiles(commit.files); + return ( +
+
+ +
+
{commit.authorName ?? "Unknown author"}
+
+ {commit.shortSha} +
+
+
+ {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
{commit.message}
+ + +
+ ); +} + +function fileTooltipAnchor(trigger: HTMLElement | null) { + if (!trigger) return null; + const panel = trigger.closest("[data-source-control-tooltip-boundary]"); + if (!panel) return trigger; + return { + contextElement: trigger, + getBoundingClientRect: () => { + const triggerRect = trigger.getBoundingClientRect(); + const panelRect = panel.getBoundingClientRect(); + const left = panelRect.left + 8; + return DOMRect.fromRect({ + x: left, + y: triggerRect.top, + width: Math.max(0, triggerRect.right - left), + height: triggerRect.height, + }); + }, + }; +} + +function TooltipMetadataRow({ label, value }: { readonly label: string; readonly value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function FilePathTooltip({ + file, + anchor, +}: { + readonly file: VcsPanelFileChange; + readonly anchor: () => ReturnType; +}) { + return ( + +
+
{file.path}
+ {file.originalPath && file.originalPath !== file.path ? ( + + ) : null} + +
+
+ ); +} + +export function WorkingFileTooltipRow({ + children, + file, + onContextMenu, + onToggle, +}: { + readonly children: ReactNode; + readonly file: VcsPanelFileChange; + readonly onContextMenu: (event: ReactMouseEvent) => void; + readonly onToggle: () => void; +}) { + const triggerRef = useRef(null); + const tooltipAnchor = useCallback(() => fileTooltipAnchor(triggerRef.current), []); + return ( + + { + if (event.target !== event.currentTarget) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onToggle(); + }} + onContextMenu={onContextMenu} + /> + } + > + {children} + + + + ); +} + +export 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"; + } +} + +export 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"; + } +} + +export function FileChangeTooltipRow({ + expanded, + file, + onFileContextMenu, + onFileToggle, + onOpenFile, + onOpenInVsCode, +}: { + readonly expanded: boolean; + readonly file: VcsPanelFileChange; + readonly onFileContextMenu?: + | ((event: ReactMouseEvent, file: VcsPanelFileChange) => void) + | undefined; + readonly onFileToggle?: ((file: VcsPanelFileChange) => void) | undefined; + readonly onOpenFile?: ((file: VcsPanelFileChange) => void) | undefined; + readonly onOpenInVsCode?: ((file: VcsPanelFileChange) => void) | undefined; +}) { + const triggerRef = useRef(null); + const tooltipAnchor = useCallback(() => fileTooltipAnchor(triggerRef.current), []); + return ( + + onFileToggle(file) : undefined} + onKeyDown={ + onFileToggle + ? (event) => { + if (event.target !== event.currentTarget) return; + 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} + + + + ); +} + +export function BranchTooltip({ + branch, + displayName, + aheadCount, + behindCount, +}: { + readonly branch: VcsRef; + readonly displayName: string; + readonly aheadCount: number; + readonly behindCount: number; +}) { + const relativeDate = formatRelativeDate(branch.lastActivityAt); + const readableDate = formatReadableDate(branch.lastActivityAt); + return ( + +
+
{displayName}
+
+ {branch.name !== displayName ? ( + + ) : null} + {branch.upstreamName ? ( + + ) : null} + {branch.worktreePath ? ( + + ) : null} + {branch.current || branch.isDefault || aheadCount > 0 || behindCount > 0 ? ( +
+ {branch.current ? current : null} + {branch.isDefault ? default : null} + +
+ ) : null} + {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
+
+
+ ); +} + +export function WorkingTreeTooltip({ + changeSet, +}: { + readonly changeSet: WorkingTreeChangeSetView; +}) { + const stats = sumFiles(changeSet.files); + return ( + +
+
+ {changeSet.current ? "Working tree" : "Sibling working tree"} +
+
+ + +
+ + {changeSet.files.length === 1 ? "1 file" : `${changeSet.files.length} files`} + + +
+
+
+
+ ); +} + +export function RemoteTooltip({ remote }: { readonly remote: VcsPanelRemote }) { + return ( + +
+
{remote.name}
+
+ + {remote.pushUrl && remote.pushUrl !== remote.fetchUrl ? ( + + ) : null} +
+ {remote.branches.length === 1 ? "1 branch" : `${remote.branches.length} branches`} +
+
+
+
+ ); +} + +export function StashTooltip({ + stash, + branchName, +}: { + readonly stash: VcsPanelStash; + readonly branchName: string | null; +}) { + const relativeDate = formatRelativeDate(stash.createdAt); + const readableDate = formatReadableDate(stash.createdAt); + return ( + +
+
{stash.message}
+
+
+ Ref {stash.refName} +
+ {branchName ? : null} + {relativeDate || readableDate ? ( +
+ {relativeDate ?? "Unknown time"} + {readableDate ? ` (${readableDate})` : null} +
+ ) : null} +
+
+
+ ); +} + +export 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} + + ); +} + +export function RowActions({ children }: { readonly children: ReactNode }) { + return ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + {children} +
+ ); +} diff --git a/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx b/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx new file mode 100644 index 00000000000..bed63519e42 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelRepositories.tsx @@ -0,0 +1,426 @@ +import type { + EnvironmentId, + ThreadId, + VcsPanelChangeGroup, + VcsPanelFileDiffInput, + VcsPanelRemote, + VcsPanelStash, + VcsPanelWorktreeChangeSet, + VcsRef, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + Archive, + ChevronDown, + ChevronRight, + GitBranch, + GitCommit, + GitPullRequestArrow, + RefreshCw, + Trash2, +} from "lucide-react"; +import { + useCallback, + useDeferredValue, + useEffect, + useId, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Textarea } from "../ui/textarea"; +import { Tooltip, TooltipTrigger } from "../ui/tooltip"; +import { + beginPanelFileDiffLoad, + branchHasUpstream, + branchOperationCwd, + branchSyncState, + drainPanelRefreshQueue, + namedBranchOperationCwd, + type PanelChangedFile, + stashIdentityKey, +} from "./SourceControlPanel.logic"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localBranchForRemoteBranch, + mapBranchDetails, + remoteBranchRef, + shouldFetchBeforePull, + stashBranchName, + splitEnrichmentFileKey, + treeKey, + worktreeChangeSetId, + commitCountLabel, + contextMenuSeparator, + type FileDiffSource, + type WorkingTreeChangeSetView, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncLabels, + CommitTooltip, + FileChangeTooltipRow, + IconButton, + RemoteTooltip, + RowActions, + StashTooltip, + WorkingFileTooltipRow, + branchSyncActionLabel, +} from "./SourceControlPanelPrimitives"; +import type { ReadySourceControlPanelController } from "./useSourceControlPanelController"; +import type { makeSourceControlPanelBranchRenderers } from "./SourceControlPanelBranches"; +import { Download } from "lucide-react"; +import { formatRelativeDate } from "./SourceControlPanel.logic"; +import { FileChangeList } from "./SourceControlPanelRows"; + +type BranchRenderers = ReturnType; + +export function makeSourceControlPanelRepositoryRenderers( + controller: ReadySourceControlPanelController, + branchRenderers: BranchRenderers, +) { + const { + api, + copyText, + cwd, + expandedTree, + fileDiffListProps, + isActionRunning, + loadStashDetails, + loadingStashDetails, + openContextMenu, + openFileChangeContextMenu, + openInVsCode, + runAction, + snapshot, + stashDetailsByKey, + toggleFileDiff, + toggleStashTree, + toggleTree, + toggleTreeFromKeyboard, + } = controller; + const { remoteBranchRow } = branchRenderers; + 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 stashKey = stashIdentityKey(stash); + const key = treeKey("stash", stashKey); + const expanded = expandedTree.has(key); + const details = stashDetailsByKey.get(stashKey); + const loadingDetails = loadingStashDetails.has(stashKey); + 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)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleStashTree(key, stash); + }} + 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} +
+ ); + }; + + return { localBranchesRow, remoteRow, stashRow }; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelRows.tsx b/apps/web/src/components/source-control/SourceControlPanelRows.tsx new file mode 100644 index 00000000000..706a17b3f92 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelRows.tsx @@ -0,0 +1,267 @@ +import type { VcsPanelFileChange, VcsPanelSnapshotResult } from "@t3tools/contracts"; +import { FileDiff, useWorkerPool } from "@pierre/diffs/react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { ComponentProps, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; + +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; + +import { Badge } from "../ui/badge"; +import type { PanelChangedFile } from "./SourceControlPanel.logic"; +import { + COLLAPSED_SECTION_HEIGHT, + COMMIT_PAGE_SIZE, + WORKING_FILE_PREFETCH_MARGIN, + sumFiles, + type SectionKey, +} from "./SourceControlPanelModel"; +import { BranchSyncLabels, FileChangeTooltipRow, StatLabels } from "./SourceControlPanelPrimitives"; + +export 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} +
+ ); +} + +export 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 ; +} + +export function FileChangeSummary({ files }: { readonly files: readonly VcsPanelFileChange[] }) { + const stats = sumFiles(files); + return ( + + {files.length === 1 ? "1 file" : `${files.length} files`} + + + ); +} + +export 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 ( +
+ + {expanded && renderExpandedFile ? ( +
{renderExpandedFile(file)}
+ ) : null} +
+ ); + })} +
+ ); +} + +export function LoadMoreCommitsButton({ + remaining, + loading, + onClick, +}: { + readonly remaining: number; + readonly loading: boolean; + readonly onClick: () => void; +}) { + if (remaining <= 0) return null; + return ( + + ); +} + +export function WorkingFileRow({ + file, + onRendered, + renderFile, +}: { + readonly file: PanelChangedFile; + readonly onRendered: (file: PanelChangedFile) => void; + readonly renderFile: (file: PanelChangedFile) => ReactNode; +}) { + const rowRef = useRef(null); + useEffect(() => { + const element = rowRef.current; + if (!element || typeof IntersectionObserver === "undefined") { + onRendered(file); + return; + } + let didRender = false; + const observer = new IntersectionObserver( + (entries) => { + if (didRender || !entries.some((entry) => entry.isIntersecting)) return; + didRender = true; + onRendered(file); + observer.disconnect(); + }, + { rootMargin: `${WORKING_FILE_PREFETCH_MARGIN}px 0px` }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [file, onRendered]); + return
{renderFile(file)}
; +} + +export 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) => ( + + ))} +
+ ); +} + +function WorkerRefreshedFileDiff(props: ComponentProps) { + const workerPool = useWorkerPool(); + const subscribe = useCallback( + (listener: () => void) => workerPool?.subscribeToStatChanges(listener) ?? (() => {}), + [workerPool], + ); + const getSnapshot = useCallback( + () => workerPool?.getDiffResultCache(props.fileDiff) !== undefined, + [props.fileDiff, workerPool], + ); + const highlightCached = useSyncExternalStore(subscribe, getSnapshot, () => false); + const renderKey = props.fileDiff.cacheKey + ? `${props.fileDiff.cacheKey}:${highlightCached ? "highlighted" : "pending"}` + : undefined; + return ; +} diff --git a/apps/web/src/components/source-control/SourceControlPanelView.tsx b/apps/web/src/components/source-control/SourceControlPanelView.tsx new file mode 100644 index 00000000000..9a91ad7b7e0 --- /dev/null +++ b/apps/web/src/components/source-control/SourceControlPanelView.tsx @@ -0,0 +1,751 @@ +import { useAtomValue } from "@effect/atom-react"; +import { FileDiff, useWorkerPool } from "@pierre/diffs/react"; +import { + ChevronDown, + Copy, + GitBranch, + GitCommit, + GitPullRequestArrow, + Plus, + RefreshCw, + Trash2, +} from "lucide-react"; +import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { getRenderablePatch, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { + isSourceControlPanelCommandInterrupted, + resolveSourceControlPanelPresentationState, + useSourceControlPanelApi, +} from "~/state/sourceControlPanel"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +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, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; +import { + readCachedSourceControlPanelState, + sourceControlPanelStateCacheKey, + writeCachedSourceControlPanelState, + type SourceControlSectionKey, +} from "./SourceControlPanelCache"; +import { + COMMIT_PAGE_SIZE, + MIN_SECTION_WEIGHT, + SECTION_ORDER, + SECTION_TITLES, + applyWorkingTreeFileEnrichment, + commitUndoActionKey, + enrichmentFileKey, + expandedBranchesForSnapshot, + fileBasename, + isActionForced, + localOnlyBranches, + operationPathsForFile, + shouldEnrichWorkingTreeFile, + stashBranchName, + splitEnrichmentFileKey, + treeKey, + worktreeChangeSetId, + commitCountLabel, + type FileDiffLoadState, + type SectionKey, +} from "./SourceControlPanelModel"; +import { + AttentionIcon, + BranchSyncLabels, + CommitTooltip, + CompactBadge, + IconButton, + RemoteTooltip, + StashTooltip, + StatLabels, + WorkingTreeTooltip, + stashActivityTimestamp, +} from "./SourceControlPanelPrimitives"; +import { makeSourceControlPanelBranchRenderers } from "./SourceControlPanelBranches"; +import { BranchBadge, CollapsibleSection } from "./SourceControlPanelRows"; +import { LoaderCircle, Upload } from "lucide-react"; +import { TooltipPopup } from "../ui/tooltip"; +import { makeSourceControlPanelRepositoryRenderers } from "./SourceControlPanelRepositories"; +import { makeSourceControlPanelWorkingTreeRenderers } from "./SourceControlPanelWorkingTree"; +import type { + ReadySourceControlPanelController, + SourceControlPanelController, +} from "./useSourceControlPanelController"; + +export function SourceControlPanelView({ + controller, +}: { + readonly controller: SourceControlPanelController; +}) { + const { + addRemoteOpen, + api, + branchDetailsByRef, + changedFiles, + chooseCompareBase, + collapsed, + commitDialogOpen, + commitMessageId, + compareBaseOverrides, + compareBaseRefs, + compareBaseDialogTarget, + compareBaseQuery, + copyText, + cwd, + dialogCommitMessage, + dialogStashMessage, + divergedSyncBranch, + error, + filteredCompareBaseRefs, + containerRef, + fetchActionableBranches, + gitAction, + isActionRunning, + loading, + presentationState, + publishBranch, + publishRemoteTarget, + refresh, + remoteName, + remoteUrl, + runAction, + runDivergedSync, + runGeneratedPanelStash, + runPanelCommit, + runPanelStash, + sectionWeights, + setAddRemoteOpen, + setCommitDialogOpen, + setCompareBaseDialogTarget, + setCompareBaseQuery, + setDialogCommitMessage, + setDialogStashMessage, + setDivergedSyncBranch, + setPublishRemoteTarget, + setRemoteName, + setRemoteUrl, + setStashDialogTarget, + selectedChangedFiles, + snapshot, + stashDialogTarget, + stashMessageId, + startSectionResize, + toggleSection, + } = controller; + const section = (key: SectionKey, children: ReactNode, action?: ReactNode) => ( + toggleSection(key)} + onResizeStart={startSectionResize} + action={action} + > + {children} + + ); + + if (presentationState.status === "loading") { + return ( +
+ {presentationState.message} +
+ ); + } + + if (presentationState.status === "unavailable") { + return ( +
+
+
+ {presentationState.message} +
+
+ copyText(error ?? "", "No error to copy.")} + > + + +
+
+ +
+ ); + } + + if (!snapshot) { + return null; + } + + const readyController: ReadySourceControlPanelController = { ...controller, snapshot }; + const branchRenderers = makeSourceControlPanelBranchRenderers(readyController); + const { branchRow } = branchRenderers; + const { localBranchesRow, remoteRow, stashRow } = makeSourceControlPanelRepositoryRenderers( + readyController, + branchRenderers, + ); + const { localBranchesWithoutUpstream, renderWorkingTreeRow, workItems } = + makeSourceControlPanelWorkingTreeRenderers(readyController); + const showRefreshSpinner = + loading && presentationState.status === "ready" && presentationState.syncMessage !== null; + const syncBannerMessage = showRefreshSpinner ? null : presentationState.syncMessage; + const repositorySummary = ( +
+ {showRefreshSpinner ? ( + + + + + } + /> + {presentationState.syncMessage} + + ) : null} +
+ + {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} +
+ {syncBannerMessage ? ( +
+
+ {syncBannerMessage} +
+
+ copyText(error ?? "", "No error to copy.")} + > + + +
+
+ ) : null} +
+ ); + + const workSection = ( +
+ {workItems.map((item) => { + switch (item.kind) { + case "working-tree": + return
{renderWorkingTreeRow(item.changeSet)}
; + 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(true)} + > + + , + ); + case "remotes": + return section( + key, + remotesSection, +
+ + void runAction( + "remotes-fetch-all", + () => + api?.vcs.fetchAllRemotes({ cwd, force: true }) ?? 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. + + + + +