From 193dd5726fc40cf73b290cd27d2e85912c5685f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:59:47 -0800 Subject: [PATCH] Git Graph tab: all branches, commit details and file changes (#6746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Git Graph view showing all branches and commit history Adds a full-pane Git Graph tab that visualizes the whole repository — all local branches, remote-tracking branches, tags, merges, and HEAD — instead of only the current branch's linear history shown in the Source Control sidebar today. - Add an opt-in allRefs scope to the shared git-history loader so the graph query enumerates --branches --remotes --tags HEAD; the default current-branch path is unchanged. The flag is forwarded through the local IPC, SSH provider, and runtime RPC boundaries so it works in all environments. - New git-graph tab content type with a full-pane GitGraphPane that reuses the existing graph SVG/row components, virtualizes rows, and opens a commit's combined diff on click. Wired into the tab strip as a chip (activate/close/keyboard-cycle) mirroring the simulator pattern. - Entry point: an Open Git Graph button in the Source Control commits header (Cmd/Ctrl-click opens in a split). Closes #4521 Co-authored-by: Orca * Address review: virtual-tab guards, nav-order, truncation copy - Treat the Git Graph (and mobile emulator) tab chip as a virtual tab: add an isVirtual contract to EditorFileTab/EditorFileTabContextMenu so rename, copy-path, copy-relative-path, and reveal-in-finder are disabled for tabs whose filePath is a display label, not an on-disk path. Prevents a rename on the synthetic 'Git Graph' path running a real file operation. - Forward gitGraphIds through the legacy reconcileTabOrder fallback in getActiveTabNavOrder so keyboard tab navigation does not drop git-graph tabs when no split-group layout exists yet. - Make the truncation banner explicit: 'Showing first N commits across all branches' (was 'of many'), across all locales. - Tests: virtual-tab hides path actions; git-graph survives the legacy nav-order fallback. Co-authored-by: Orca * Forward allRefs over SSH relay and web runtime paths Two boundaries re-destructured git.history params and dropped the new allRefs flag, leaving the repo-wide Git Graph inert (linear default-branch history) for SSH and web-runtime worktrees: - src/relay/git-handler.ts: the relay host's git.history handler forwarded only { limit, baseRef }. Add allRefs so the SSH host enumerates all refs. Adds an end-to-end relay test (a feature-branch commit absent from linear history appears only when allRefs is forwarded). - src/renderer/src/web/web-preload-api.ts: the web preload history shim destructured only { worktreePath, limit, baseRef }. Forward allRefs. Also fix a request-id race in useGitGraphHistory: bump requestIdRef before the null-context early return so an in-flight fetch from a prior worktree can't resolve and overwrite the 'idle' state after the worktree clears. Co-authored-by: Orca * Support filtered and pinned Git history Resolve branch and tag roots on the host, keep paging tied to the chosen revision set, and echo the applied filter across Git transports. Co-authored-by: Matheus Breguêz * Add the Git Graph tab and commit details Open the all-branch view from Source Control or the command palette, preserve older clients' layouts, and keep the sidebar HEAD-only. * Restore files the main merge left at stale versions The earlier merge of origin/main kept older or missing copies of 61 files unrelated to the Git Graph (CI script tests, reliability gates, shard timings and five terminal runtime files). Take main's version of each so the PR changes only its own files. * Limit pinned history to explicit graph scopes Keep All and HEAD snapshots free of redundant ref names, and filter ref searches before nested-tag peeling. Default history requests retain unpinned paging without evicting graph tokens. Co-authored-by: Matheus Breguêz * Preserve canonical placement for older graph clients Pass graph-free record deltas through without rebuilding layouts, retain existing empty groups, and translate content creation into canonical groups while preserving hidden graph tabs. * Retain Git Graph browsing state for the tab lifetime Own history retrieval, selection and scroll in one renderer controller that retires on close or owner/filter changes. Accumulate parent pages before publishing, share action-time workspace navigation, serialize ref searches, and scope refreshes to the workspace. * Apply Git Graph branch selections on picker close Keep checkbox changes local until dismissal, then normalize and apply one chosen ref set. Allow picker reads to use a qualified host scope. * Keep Git Graph navigation bound to its tab owner Retain the tab's qualified owner and Git connection across workspace focus changes. Newer selections and refreshes supersede pending parent navigation without losing fetched pages. Report stale workspace switches and let the commit menu handle empty space in the Branch / Tag cell. Cover twin-owner retention, superseded navigation and picker apply-on-close with renderer regressions. * Translate desktop layout commands against canonical state Resolve older desktops' visible groups, tab gaps and split paths inside the canonical mutation, including detach and cross-partition moves. Keep initial admission visibility-aware and cache graph presence for binding-only frames. * Refine Git Graph picker and owner transitions Discard branch drafts on Escape or owner changes, follow updated tab ownership, and report workspace lookup failures for the current owner. Keep parent navigation queued behind pending history loads. * Drop the redundant default from the exhaustive layout command switch * Align Git Graph with changed-code quality rules Use typed fixtures and an event guard, keep primitive styling on content wrappers, and prune loaded-commit bookkeeping after render. * Route Git Graph opening through the existing editor action Use workspace record selectors for stable owner inputs and remove two loading labels orphaned by the graph changes from all locale catalogs. * Preserve Git Graph selection and keep row content visible Refresh and token recovery retain loaded depth and selection in one accumulating operation. Keep the published view until it settles, and only fall back to the first commit after history is exhausted. Prioritize current and open-workspace refs, reserve their markers, clip wide graphs and retain filenames when long paths need truncation. * Bound Git Graph refresh and allow graph column resizing Restore the loaded depth without searching for vanished selections, retain the published result on retrieval errors, and keep newer selections visible. Allow the bounded graph column to expand to every lane through the existing resize mechanism. * Retain Git Graph history for errors without a message Detect failed retrieval independently of its message and cover an empty-message rejection in the existing partial-failure regression. * Retain Git Graph widths and preserve shifted selections Separate committed and live resize widths, retaining the chosen width for the graph tab lifetime. Allow one bounded extra history page for selections shifted by new commits, and give the resize separator a translated label. * Preserve nullable Git Graph owner fallback * Fix Git Graph layout imports after the RPC method move * Route the Git Graph owner connection through the source-control connection helper * Keep the Git Graph button idle in hidden Source Control sections * Re-probe Git Graph support when the owner's runtime status changes --------- Co-authored-by: Orca Co-authored-by: Matheus Breguêz --- src/main/git/history.ts | 39 +- .../desktop-renderer-runtime-capabilities.ts | 2 + .../filesystem-git-history-handlers.ts | 54 ++ .../filesystem-git-status-handlers.ts | 34 +- .../applying-settings/ui-state-read.ts | 2 + .../applying-settings/ui-state-update.ts | 5 + .../normalize-loaded-ui-state.ts | 2 + .../repo-lifecycle-ui-residue.ts | 6 +- .../wsl-move-ui-routing-rewrite.ts | 10 + .../cross-partition-tab-move.ts | 9 +- .../editor-tab-layout-apply.test.ts | 141 ++++ .../layout-command-commit.ts | 5 +- .../layout-command-persistence.ts | 10 +- .../terminal-layout-apply.test.ts | 71 ++ .../terminal-layout-pane-ops.test.ts | 41 ++ .../terminal-pane-admission.ts | 5 +- .../terminal-topology-commit.ts | 3 +- .../worktree-identity-migration.ts | 3 + src/main/providers/git-provider-contract.ts | 2 + src/main/providers/local-git-provider.ts | 3 +- .../integrations/git/orca-runtime-git.ts | 2 + .../git/runtime-git-command-surface.ts | 2 + .../git/runtime-git-status-commands.ts | 5 + src/main/runtime/orca-runtime.ts | 2 +- .../rpc/git-graph-layout-projection.test.ts | 612 +++++++++++++++ .../rpc/git-graph-layout-projection.ts | 104 +++ .../rpc/methods/source-control/git/git.ts | 9 + .../workspace-layout-apply.test.ts | 8 +- .../workspace-layout/workspace-layout.test.ts | 17 +- .../workspace-layout/workspace-layout.ts | 15 +- .../terminal/layout/runtime-layout-apply.ts | 13 +- src/preload/api/git-bridge.ts | 6 + src/preload/api/git-inspection-api.ts | 6 + .../src/app-shell/use-persisted-ui-writer.ts | 1 + .../src/components/cmd-j/quick-actions.ts | 22 + .../src/components/editor/EditorContent.tsx | 5 + .../src/components/editor/editor-header.ts | 8 + .../src/components/editor/editor-labels.ts | 3 + .../editor/git-graph/git-graph-details.tsx | 97 +++ .../editor/git-graph/git-graph-filter.tsx | 225 ++++++ .../git-graph/git-graph-history-controller.ts | 314 ++++++++ .../editor/git-graph/git-graph-ref-menu.tsx | 144 ++++ .../git-graph/git-graph-rendering.test.tsx | 696 ++++++++++++++++++ .../editor/git-graph/git-graph-tab.tsx | 276 +++++++ .../git-graph-workspace-navigation.ts | 89 +++ .../editor/git-graph/use-git-graph-history.ts | 38 + .../retention/closed-editor-tab-controller.ts | 4 + .../commit/use-commit-action.ts | 5 + .../source-control/panel/panel-content.tsx | 1 + .../source-control/panel/worktree-scope.tsx | 8 +- .../sync/git-graph-open-button.tsx | 62 ++ .../sync/git-history-commit-files.tsx | 35 +- .../source-control/sync/git-history-panel.tsx | 156 ++-- .../source-control/sync/remote-refresh.ts | 4 + .../source-control/sync/use-conflict-abort.ts | 1 + .../sync/use-git-history-commit-actions.ts | 10 +- .../sync/use-remote-action-runner.ts | 1 + .../src/components/tab-bar/EditorFileTab.tsx | 35 +- .../components/tab-bar/RecentTabSwitcher.tsx | 5 +- ...use-worktree-jump-palette-quick-actions.ts | 51 +- ...worktree-jump-palette-selection-actions.ts | 3 + ...ktree-jump-palette-project-action-rows.tsx | 1 + src/renderer/src/i18n/locales/de.json | 29 +- src/renderer/src/i18n/locales/en.json | 29 +- src/renderer/src/i18n/locales/es.json | 29 +- src/renderer/src/i18n/locales/fr.json | 29 +- src/renderer/src/i18n/locales/ja.json | 29 +- src/renderer/src/i18n/locales/ko.json | 29 +- src/renderer/src/i18n/locales/pt.json | 29 +- src/renderer/src/i18n/locales/ru.json | 29 +- src/renderer/src/i18n/locales/tr.json | 29 +- src/renderer/src/i18n/locales/zh-Hant.json | 29 +- src/renderer/src/i18n/locales/zh.json | 29 +- src/renderer/src/lib/git-graph-tab.ts | 157 ++++ .../src/runtime/mobile-session-tab-close.ts | 8 +- .../src/runtime/runtime-git-status-client.ts | 20 + .../src/runtime/server-layout-frame-ids.ts | 1 + .../editor/git/git-status-reconciliation.ts | 6 +- .../editor/tabs/editor-tab-content-type.ts | 14 +- .../editor-tab-open-through-layout-owner.ts | 3 +- .../editor/tabs/open-file-layout-view.ts | 2 + .../editor/tabs/workspace-editor-item.ts | 12 +- .../store/slices/editor/types/open-file.ts | 2 +- .../slices/persisted-ui-write-baseline.ts | 5 + .../ui/ui-slice-contract-preferences.ts | 1 + .../slices/ui/ui-slice-hydration-actions.ts | 1 + .../slices/ui/ui-slice-preference-actions.ts | 1 + .../session/worktree-identity-rename-state.ts | 1 + .../teardown/remove-worktree-store-cleanup.ts | 1 + .../teardown/worktree-purge-state.ts | 1 + .../src/web/preload-api/web-git-api.ts | 12 +- .../web/preload-api/web-git-history-api.ts | 28 + src/shared/editor-tab-view-types.ts | 1 + ...tron-remote-runtime-client-capabilities.ts | 2 + src/shared/git/history/git-graph-filters.ts | 22 + .../git/history/git-history-execution.ts | 121 ++- .../git-history-real-repository.test.ts | 120 ++- .../git/history/git-history-revisions.ts | 76 ++ .../git/history/git-history-token-store.ts | 74 ++ src/shared/git/history/git-history-types.ts | 12 +- src/shared/persisted-ui-state-types.ts | 2 + src/shared/rpc-contract/client-ui-params.ts | 7 + src/shared/rpc-contract/git-params.ts | 10 + .../rpc-params-catalog.generated.ts | 2 + .../workspace-layout-editor-file-params.ts | 3 +- .../rpc-contract/workspace-layout-params.ts | 2 +- .../runtime/git-graph-capabilities.ts | 2 + .../source-control-runtime-capabilities.ts | 3 + src/shared/tab-types.ts | 1 + src/shared/web-runtime-client-capabilities.ts | 2 + .../old-client-session-tabs-projection.ts | 1 + .../saved-editor-tab-restore.ts | 5 +- src/shared/workspace-layout/tab-order.ts | 3 +- .../workspace-layout-client-command.ts | 173 +++++ .../workspace-layout-client-visibility.ts | 74 ++ .../workspace-layout-command-steps.ts | 37 +- .../workspace-layout-command-types.ts | 2 + .../workspace-layout-commands.ts | 9 +- .../workspace-layout-content-commands.ts | 5 +- .../workspace-layout-group-commands.ts | 26 +- .../workspace-layout-initial-surface.ts | 8 +- ...rkspace-layout-load-view-less-tabs.test.ts | 3 +- .../workspace-layout-records.ts | 14 +- .../workspace-layout-tab-commands.ts | 9 +- .../workspace-layout-workspace-move.ts | 3 +- .../workspace-session-tab-type-schema.ts | 1 + .../wsl-move/wsl-move-ui-dispositions.ts | 1 + 127 files changed, 4704 insertions(+), 275 deletions(-) create mode 100644 src/main/ipc/filesystem/filesystem-git-history-handlers.ts create mode 100644 src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts create mode 100644 src/main/runtime/rpc/git-graph-layout-projection.test.ts create mode 100644 src/main/runtime/rpc/git-graph-layout-projection.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-details.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-filter.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-tab.tsx create mode 100644 src/renderer/src/components/editor/git-graph/git-graph-workspace-navigation.ts create mode 100644 src/renderer/src/components/editor/git-graph/use-git-graph-history.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/sync/git-graph-open-button.tsx create mode 100644 src/renderer/src/lib/git-graph-tab.ts create mode 100644 src/renderer/src/web/preload-api/web-git-history-api.ts create mode 100644 src/shared/git/history/git-graph-filters.ts create mode 100644 src/shared/git/history/git-history-revisions.ts create mode 100644 src/shared/git/history/git-history-token-store.ts create mode 100644 src/shared/source-control/runtime/git-graph-capabilities.ts create mode 100644 src/shared/workspace-layout/workspace-layout-client-command.ts create mode 100644 src/shared/workspace-layout/workspace-layout-client-visibility.ts diff --git a/src/main/git/history.ts b/src/main/git/history.ts index 932c33ef83c..50d2bd8292e 100644 --- a/src/main/git/history.ts +++ b/src/main/git/history.ts @@ -1,3 +1,6 @@ +import { resolveWorktreeHostPath } from '../../shared/git/metadata/git-metadata-path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { loadGitHistoryRefs } from '../../shared/git/history/git-history-revisions' import type { GitHistoryOptions, GitHistoryResult @@ -12,8 +15,42 @@ export async function getHistory( options: GitHistoryOptions & GitRuntimeOptions = {} ): Promise { return loadGitHistoryFromExecutor( - (args, cwd) => gitExecFileAsync(args, gitOptionsForWorktree(cwd, options)), + (args, cwd, stdin) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(cwd, options), + ...(stdin !== undefined ? { stdin } : {}) + }), worktreePath, options ) } + +export async function getHistoryRefs( + worktreePath: string, + query?: string, + options: GitRuntimeOptions = {} +) { + return ( + await loadGitHistoryRefs( + (args, cwd, stdin) => + gitExecFileAsync(args, { + ...gitOptionsForWorktree(cwd, options), + ...(stdin !== undefined ? { stdin } : {}) + }), + worktreePath, + query + ) + ) + .slice(0, 1000) + .map((ref) => + ref.worktreePath + ? { + ...ref, + worktreePath: + resolveWorktreeHostPath(ref.worktreePath, { + wslDistro: parseWslUncPath(worktreePath)?.distro ?? options.wslDistro + }) ?? ref.worktreePath + } + : ref + ) +} diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 0b5be95ddc5..6f4392173e2 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -1,3 +1,4 @@ +import { GIT_GRAPH_TAB_CAPABILITY } from '../../shared/source-control/runtime/git-graph-capabilities' import { REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, type RuntimeCapability @@ -15,6 +16,7 @@ import { /** Curate one capability set for unary/streaming desktop calls; remote-only gates can alter local semantics even on the same build. */ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapability[] = [ + GIT_GRAPH_TAB_CAPABILITY, ...STRUCTURED_AGENT_SESSION_RENDERER_CLIENT_CAPABILITIES, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, // Agent-launch authorization requires this even for the same-build desktop renderer. diff --git a/src/main/ipc/filesystem/filesystem-git-history-handlers.ts b/src/main/ipc/filesystem/filesystem-git-history-handlers.ts new file mode 100644 index 00000000000..764ddf49a41 --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-git-history-handlers.ts @@ -0,0 +1,54 @@ +import { ipcMain } from 'electron' +import type { + GitHistoryOptions, + GitHistoryResult +} from '../../../shared/git/history/git-history-types' +import { getHistory, getHistoryRefs } from '../../git/history' +import { getConnectionExecutionHostId } from '../../../shared/execution-host' +import { requireReachableGitRoute } from '../../providers/execution-host-provider-dispatch' +import { readWorktreeGitUnlessWslStopped } from './wsl-gated-worktree-git-read' +import type { FilesystemHandlerContext } from './filesystem-handler-context' + +export function registerFilesystemGitHistoryHandlers({ store }: FilesystemHandlerContext): void { + ipcMain.handle( + 'git:refs', + async (_event, args: { worktreePath: string; connectionId?: string; query?: string }) => { + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + return readWorktreeGitUnlessWslStopped( + store, + args.worktreePath, + false, + (worktreePath, gitOptions) => getHistoryRefs(worktreePath, args.query, gitOptions) + ) + } + ) + + ipcMain.handle( + 'git:history', + async ( + _event, + args: { + worktreePath: string + connectionId?: string + /** Refresh: a user act that may start a stopped WSL distro. */ + wakeWslDistro?: boolean + } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { + limit: args.limit, + baseRef: args.baseRef, + branchOnly: args.branchOnly, + refs: args.refs, + historyToken: args.historyToken, + skip: args.skip + } + requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) + return readWorktreeGitUnlessWslStopped( + store, + args.worktreePath, + args.wakeWslDistro, + (worktreePath, gitOptions) => getHistory(worktreePath, { ...options, ...gitOptions }) + ) + } + ) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts index 7d20c9d7227..e28b3bb6d8a 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -5,10 +5,6 @@ import type { GitStatusResult } from '../../../shared/git/status/git-status-types' import type { GitDiffResult } from '../../../shared/git/diff/git-diff-compare-types' -import type { - GitHistoryOptions, - GitHistoryResult -} from '../../../shared/git/history/git-history-types' import type { GitStatusRefBindingRequest as GitStatusUpstreamRefWatchRequest } from '../worktrees/git/worktree-git-status-ref-watch' import type { GitAdmissionTier } from '../../git/command-runner/git-exec-options' import { getStatus } from '../../git/source-control/status-read' @@ -19,7 +15,7 @@ import { detectConflictOperation } from '../../git/source-control/git-conflict-operation' import { getDiff } from '../../git/source-control/file-diff' -import { getHistory } from '../../git/history' +import { registerFilesystemGitHistoryHandlers } from './filesystem-git-history-handlers' import { isGitWorktreeMutationInFlight } from '../../git/command-runner/git-worktree-mutation-state' import type { GitMutationInFlight } from '../../../shared/git/commands/git-mutation-in-flight' import { checkIgnoredPaths } from '../../git/source-control/check-ignored-paths' @@ -49,6 +45,7 @@ import { export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerContext): void { const { store, gitStatusCancellations } = context + registerFilesystemGitHistoryHandlers(context) ipcMain.handle( 'git:status', async ( @@ -190,33 +187,6 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo } ) - ipcMain.handle( - 'git:history', - async ( - _event, - args: { - worktreePath: string - connectionId?: string - /** Refresh: a user act that may start a stopped WSL distro. */ - wakeWslDistro?: boolean - } & GitHistoryOptions - ): Promise => { - const options: GitHistoryOptions = { - limit: args.limit, - baseRef: args.baseRef, - branchOnly: args.branchOnly, - skip: args.skip - } - requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId)) - return readWorktreeGitUnlessWslStopped( - store, - args.worktreePath, - args.wakeWslDistro, - (worktreePath, gitOptions) => getHistory(worktreePath, { ...options, ...gitOptions }) - ) - } - ) - // Filesystem probes clear inactive-worktree conflict badges without a full Git status. ipcMain.handle( 'git:conflictOperation', diff --git a/src/main/persistence/applying-settings/ui-state-read.ts b/src/main/persistence/applying-settings/ui-state-read.ts index d95f8ee4559..7d91463a475 100644 --- a/src/main/persistence/applying-settings/ui-state-read.ts +++ b/src/main/persistence/applying-settings/ui-state-read.ts @@ -1,3 +1,4 @@ +import { normalizeGitGraphFilters } from '../../../shared/git/history/git-graph-filters' import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { @@ -72,6 +73,7 @@ export function getPersistedUI( explorerDisplayRootByWorktree: normalizeExplorerDisplayRootByWorktree( state.ui?.explorerDisplayRootByWorktree ), + gitGraphFiltersByWorktree: normalizeGitGraphFilters(state.ui?.gitGraphFiltersByWorktree), showDotfilesByWorktree: normalizeShowDotfilesByWorktree(state.ui?.showDotfilesByWorktree), featureTipsSeenIds: normalizeFeatureTipIds(state.ui?.featureTipsSeenIds), contextualToursSeenIds: normalizeContextualTourIds(state.ui?.contextualToursSeenIds), diff --git a/src/main/persistence/applying-settings/ui-state-update.ts b/src/main/persistence/applying-settings/ui-state-update.ts index 7b456a14c38..b294f577524 100644 --- a/src/main/persistence/applying-settings/ui-state-update.ts +++ b/src/main/persistence/applying-settings/ui-state-update.ts @@ -1,3 +1,4 @@ +import { normalizeGitGraphFilters } from '../../../shared/git/history/git-graph-filters' import { normalizeExplorerDisplayRootByWorktree } from '../../../shared/file-explorer-display-root' import type { PersistedState } from '../../../shared/persisted-state-types' import { @@ -190,6 +191,10 @@ export function updatePersistedUI( : normalizeExplorerDisplayRootByWorktree( operations.state.ui?.explorerDisplayRootByWorktree ), + gitGraphFiltersByWorktree: + sanitizedUpdates.gitGraphFiltersByWorktree !== undefined + ? normalizeGitGraphFilters(sanitizedUpdates.gitGraphFiltersByWorktree) + : operations.state.ui?.gitGraphFiltersByWorktree, showDotfilesByWorktree: sanitizedUpdates.showDotfilesByWorktree !== undefined ? normalizeShowDotfilesByWorktree(sanitizedUpdates.showDotfilesByWorktree) diff --git a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts index 5405a62971e..edc2fda00e4 100644 --- a/src/main/persistence/loading-store/normalize-loaded-ui-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-ui-state.ts @@ -1,3 +1,4 @@ +import { normalizeGitGraphFilters } from '../../../shared/git/history/git-graph-filters' import { resolveStatusBarCompactChangeNoticeDismissed } from '../../../shared/status-bar-compact-change-notice' import { migrateExplorerDisplayRoots } from '../../../shared/file-explorer-display-root' import { @@ -222,6 +223,7 @@ export function normalizeLoadedUiState( sortBy: migrate ? ('smart' as const) : sort, _explorerDisplayRootMigrated: true, explorerDisplayRootByWorktree, + gitGraphFiltersByWorktree: normalizeGitGraphFilters(parsed.ui?.gitGraphFiltersByWorktree), showDotfilesByWorktree: normalizeShowDotfilesByWorktree(parsed.ui?.showDotfilesByWorktree), workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, diff --git a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts index 1bd943a6a1e..2c67c68cdd7 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-ui-residue.ts @@ -14,7 +14,11 @@ export function pruneDeregisteredRepoUiResidue( ui.lastActiveWorktreeId = null } ui.filterRepoIds = ui.filterRepoIds?.filter((repoId) => !orphanRepoIds.has(repoId)) ?? [] - for (const record of [ui.explorerDisplayRootByWorktree, ui.showDotfilesByWorktree]) { + for (const record of [ + ui.explorerDisplayRootByWorktree, + ui.showDotfilesByWorktree, + ui.gitGraphFiltersByWorktree + ]) { for (const worktreeId of Object.keys(record ?? {})) { if (isOrphanWorktree(worktreeId)) { delete record?.[worktreeId] diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts index 99e456127fa..0ac38bf50d0 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-ui-routing-rewrite.ts @@ -28,6 +28,7 @@ export type WslMoveUiRoutingState = Pick< PersistedUIState, | 'lastActiveWorktreeId' | 'showDotfilesByWorktree' + | 'gitGraphFiltersByWorktree' | 'explorerDisplayRootByWorktree' | 'workspaceCleanup' | 'manualRepoOrder' @@ -61,6 +62,15 @@ export function rewriteWslMoveUiRouting( if (dotfiles) { updates.showDotfilesByWorktree = dotfiles } + const graphFilters = withHostKeys( + ui.gitGraphFiltersByWorktree, + hostWorktreeKey, + (value) => value, + scope.inScope + ) + if (graphFilters) { + updates.gitGraphFiltersByWorktree = graphFilters + } const roots = withHostKeys( ui.explorerDisplayRootByWorktree, hostWorktreeKey, diff --git a/src/main/persistence/terminal-topology/cross-partition-tab-move.ts b/src/main/persistence/terminal-topology/cross-partition-tab-move.ts index beaa5b136e6..5a96cc140ae 100644 --- a/src/main/persistence/terminal-topology/cross-partition-tab-move.ts +++ b/src/main/persistence/terminal-topology/cross-partition-tab-move.ts @@ -1,5 +1,6 @@ // Main moves editor tabs across partitions and writes only changed records, retaining in-memory views. +import type { LayoutClientVisibility } from '../../../shared/workspace-layout/workspace-layout-client-visibility' import { randomUUID } from 'node:crypto' import type { ExecutionHostId } from '../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types' @@ -17,7 +18,7 @@ import type { } from '../../../shared/workspace-layout/workspace-layout-model' import { patchStoredSession } from '../../../shared/workspace-layout/workspace-layout-record-patch' import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' -import { moveTabToWorkspace } from '../../../shared/workspace-layout/workspace-layout-workspace-move' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' import { editorViewsOfModel, type PartitionEditorViews } from '../loading-store/layout-editor-views' type PartitionSession = { @@ -72,7 +73,8 @@ function written( export function moveTabAcrossPartitions( from: PartitionSession, to: PartitionSession, - command: CommandOf<'moveTabToWorkspace'> + command: CommandOf<'moveTabToWorkspace'>, + visibility?: LayoutClientVisibility ): CrossPartitionTabMove { const loadedFrom = loadWorkspaceLayout(from.hostId, from.session, loadContext(from)) const loadedTo = loadWorkspaceLayout(to.hostId, to.session, loadContext(to)) @@ -88,7 +90,8 @@ export function moveTabAcrossPartitions( command.toWorkspace, loadedTo.layout.workspaces[command.toWorkspace] ) - const moved = moveTabToWorkspace(joined, command, { + const moved = applyLayoutCommand(joined, command, { + visibility, mintId: randomUUID, mintLeafId: randomUUID, now: Date.now diff --git a/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts new file mode 100644 index 00000000000..43197bb7b45 --- /dev/null +++ b/src/main/persistence/terminal-topology/editor-tab-layout-apply.test.ts @@ -0,0 +1,141 @@ +import { withoutGitGraph } from '../../../shared/workspace-layout/workspace-layout-client-visibility' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { Store } from '../loading-store/store' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { applyWindowLayoutCommand } from '../../runtime/terminal/layout/runtime-layout-apply' +import { WindowSession } from './terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_GIT_WORKTREE_ID, + openTopologyStore, + reopenTopologyStore +} from './terminal-topology-profile-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const TAB = 'tab-1' +const LEAF = '11111111-1111-4111-8111-111111111111' +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +async function storeWithTerminal(worktreeId: string): Promise<{ store: Store; directory: string }> { + const directory = mkdtempSync(join(tmpdir(), 'orca-editor-apply-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const window = new WindowSession(getDefaultWorkspaceSession()) + window.addTab(worktreeId, TAB, LEAF) + store.setWorkspaceSession(window.snapshot()) + await store.flushPendingOrThrowAsync() + return { store, directory } +} + +function editorOpen(worktreeId: string, tabId: string, path: string) { + return { + type: 'openEditorTab', + workspace: worktreeId, + tabId, + fileId: path, + contentType: 'editor', + groupId: `group-${worktreeId}`, + preview: true, + file: { filePath: path, relativePath: path.slice(1), language: 'typescript' } + } as const +} + +describe('a window moveTabToWorkspace between a local and an SSH workspace', () => { + const SSH_HOST = 'ssh:target-1' + const SSH_WORKTREE = 'repo-ssh::/home/dev/api' + const routes: Record = { + [FIXTURE_GIT_WORKTREE_ID]: 'local', + [SSH_WORKTREE]: SSH_HOST + } + const hostIdsFor = (worktreeId: string) => (routes[worktreeId] ? [routes[worktreeId]] : []) + + it.each([ + ['local to SSH', FIXTURE_GIT_WORKTREE_ID, SSH_WORKTREE], + ['SSH to local', SSH_WORKTREE, FIXTURE_GIT_WORKTREE_ID] + ])('moves a restored editor tab %s in one durable write', async (_name, from, to) => { + const { store, directory } = await storeWithTerminal(FIXTURE_GIT_WORKTREE_ID) + const path = '/fixture/other/readme.md' + const open = { ...editorOpen(from, 'e1', path), preview: undefined } + await store.applyLayoutCommand({ ...open, file: { ...open.file, relativePath: path } }, [ + routes[from]! + ]) + const newId = `editor:${to}:${path}` + const held = store.getWorkspaceSession(routes[to]).tabGroups?.[to]?.[0]?.id + await store.applyLayoutCommand( + { + type: 'openEditorTab', + workspace: to, + tabId: 'graph', + fileId: 'graph', + contentType: 'git-graph', + view: { + view: { mode: 'git-graph' }, + file: { filePath: 'graph', relativePath: 'graph', language: 'plaintext' } + }, + groupId: 'hidden', + ...(held ? { split: { besideGroupId: held, direction: 'right' as const } } : {}) + }, + [routes[to]!] + ) + const destination = held ?? 'hidden' + + await expect( + applyWindowLayoutCommand( + store, + hostIdsFor, + { + type: 'moveTabToWorkspace', + workspace: from, + toWorkspace: to, + tabs: [{ tabId: 'e1', newTabId: 'e1', newEntityId: newId }], + groupId: `group-${to}`, + file: { filePath: path, relativePath: 'readme.md', language: 'markdown' } + }, + undefined, + { visibility: withoutGitGraph } + ) + ).resolves.toEqual({ ok: true, result: { tabId: 'e1', groupId: destination } }) + + // Inspect before reload: this fixture lacks the SSH repo needed to retain its workspace. + const source = store.getWorkspaceSession(routes[from]) + const target = store.getWorkspaceSession(routes[to]) + expect(target.tabGroups?.[to]?.find((group) => group.id === destination)?.tabOrder).toEqual( + held ? [TAB, 'e1'] : ['graph', 'e1'] + ) + expect(target.unifiedTabs?.[to]?.find((tab) => tab.id === 'graph')?.contentType).toBe( + 'git-graph' + ) + expect(source.unifiedTabs?.[from]?.some((tab) => tab.id === 'e1') ?? false).toBe(false) + expect(source.openFilesByWorktree?.[from] ?? []).toEqual([]) + expect(target.unifiedTabs?.[to]?.find((tab) => tab.id === 'e1')).toMatchObject({ + entityId: newId + }) + expect(target.openFilesByWorktree?.[to]).toEqual([ + expect.objectContaining({ filePath: path, relativePath: 'readme.md', worktreeId: to }) + ]) + const reopened = await reopenTopologyStore(store, directory) + expect(reopened.getWorkspaceSession('local').tabsByWorktree[FIXTURE_GIT_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TAB }) + ]) + expect( + reopened + .getWorkspaceSession('local') + .unifiedTabs?.[FIXTURE_GIT_WORKTREE_ID]?.some((tab) => tab.id === 'e1') + ).toBe(to === FIXTURE_GIT_WORKTREE_ID) + }) +}) diff --git a/src/main/persistence/terminal-topology/layout-command-commit.ts b/src/main/persistence/terminal-topology/layout-command-commit.ts index 6ec13912780..5d2ec6a31d3 100644 --- a/src/main/persistence/terminal-topology/layout-command-commit.ts +++ b/src/main/persistence/terminal-topology/layout-command-commit.ts @@ -46,7 +46,8 @@ export function keepFrozen( export function commitWindowPaneDetach( hostIds: readonly ExecutionHostId[], command: WindowPaneDetachCommand, - context: TerminalTopologyCommitContext + context: TerminalTopologyCommitContext, + run: LayoutCommandRun = {} ): DurableProfileStateMutation { const resolved = resolveWindowPaneDetach( context.hostIds().map((hostId) => ({ hostId, session: context.getSession(hostId) })), @@ -56,7 +57,7 @@ export function commitWindowPaneDetach( if (resolved && 'code' in resolved) { return { value: { ok: false, code: resolved.code }, persist: false } } - const committed = commitLayoutCommand(hostIds, resolved?.command ?? command, context) + const committed = commitLayoutCommand(hostIds, resolved?.command ?? command, context, run) if (!resolved || !committed.value.ok || committed.persist === false) { return committed } diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 22a6b716cfe..ad359384af3 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -1,3 +1,4 @@ +import type { LayoutClientVisibility } from '../../../shared/workspace-layout/workspace-layout-client-visibility' import type { WorkspaceSessionState } from '../../../shared/workspace-session/workspace-session-state-types' import type { CommandOf, @@ -82,6 +83,7 @@ export class LayoutCommandPersistence { run: { admit?: (partitions?: readonly WorkspaceSessionState[]) => boolean live?: LayoutLiveContext + visibility?: LayoutClientVisibility stopBeforeWrite?: (ptyIds: readonly string[]) => boolean onEffects?: (effects: LayoutEffects) => void } = {} @@ -94,7 +96,7 @@ export class LayoutCommandPersistence { resolved, (hostId) => context.sessions.getWorkspaceSession(hostId), applyLayout(resolved, command, topologyCommitContext(context), { - live: run.live, + live: { ...run.live, visibility: run.visibility }, stopBeforeWrite: run.stopBeforeWrite, noteEffects: (noted) => (effects = noted) }) @@ -161,7 +163,8 @@ export class LayoutCommandPersistence { moveTabToWorkspaceAcrossPartitions( command: CommandOf<'moveTabToWorkspace'>, fromHostId: string, - toHostId: string + toHostId: string, + run?: { visibility?: LayoutClientVisibility } ): Promise { const commit = topologyCommitContext(this[layoutCommandPersistenceContext]) const [from, to] = [resolveHostId(fromHostId), resolveHostId(toHostId)] @@ -174,7 +177,8 @@ export class LayoutCommandPersistence { views: partitionEditorViewsOf(runtime, from) }, { hostId: to, session: commit.getSession(to), views: partitionEditorViewsOf(runtime, to) }, - command + command, + run?.visibility ) if (!moved.sessions) { return { value: moved.value, persist: false } diff --git a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts index c2d7af4bbee..dc68ab7c110 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-apply.test.ts @@ -1,3 +1,5 @@ +import { withoutGitGraph } from '../../../shared/workspace-layout/workspace-layout-client-visibility' +import type { LayoutEffects } from '../../../shared/workspace-layout/workspace-layout-command-types' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -180,3 +182,72 @@ describe('Store.applyLayoutCommand (layout.apply)', () => { expect(store.getWorkspaceSession()).toEqual(local) }) }) + +it('admits competing initial requests against visible tabs inside the durable mutation', async () => { + const { store } = await storeWithOneTab() + await store.applyLayoutCommand({ + type: 'closeTabs', + workspace: WORKTREE, + tabIds: [TAB], + force: true + }) + await store.applyLayoutCommand({ + type: 'openEditorTab', + workspace: WORKTREE, + tabId: 'graph', + fileId: 'graph', + contentType: 'git-graph', + view: { + view: { mode: 'git-graph' }, + file: { filePath: 'graph', relativePath: 'graph', language: 'plaintext' } + }, + groupId: 'hidden' + }) + const effects: LayoutEffects[] = [] + const run = { + visibility: withoutGitGraph, + onEffects: (effect: LayoutEffects) => effects.push(effect) + } + await expect( + store.applyLayoutCommand( + { type: 'createTerminalTab', workspace: WORKTREE, tabId: 'restored', initial: 'restore' }, + ['local'], + run + ) + ).resolves.toEqual({ ok: true, result: {} }) + const replies = await Promise.all([ + store.applyLayoutCommand( + { + type: 'createTerminalTab', + workspace: WORKTREE, + tabId: 'first', + leafId: LEAF, + groupId: 'old-root', + initial: 'activation' + }, + ['local'], + run + ), + store.applyLayoutCommand( + { + type: 'openAgentSessionTab', + workspace: WORKTREE, + tabId: 'second', + sessionId: 'session', + agent: 'codex', + initial: 'activation' + }, + ['local'], + run + ) + ]) + expect(replies).toMatchObject([ + { ok: true, result: { tabId: 'first' } }, + { ok: true, result: {} } + ]) + expect( + store.getWorkspaceSession().tabGroups?.[WORKTREE]?.map(({ id, tabOrder }) => ({ id, tabOrder })) + ).toEqual([{ id: 'hidden', tabOrder: ['graph', 'first'] }]) + expect(effects.flatMap((effect) => effect.startPaneKeys)).toEqual([`first:${LEAF}`]) + expect(effects.flatMap((effect) => effect.stopPtyIds)).toEqual([]) +}) diff --git a/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts index 62f6a9dc77c..522f710b9ac 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-pane-ops.test.ts @@ -1,3 +1,4 @@ +import { withoutGitGraph } from '../../../shared/workspace-layout/workspace-layout-client-visibility' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -84,6 +85,46 @@ describe('Store.applyLayoutCommand pane operations', () => { return store } + it('keeps window-detach visibility and pane bindings through the atomic commit', async () => { + const store = await storeWithSplitTab(['local']) + const groupId = store.getWorkspaceSession().tabGroups?.[WORKTREE]?.[0]!.id + await store.applyLayoutCommand({ + type: 'openEditorTab', + workspace: WORKTREE, + tabId: 'graph', + fileId: 'graph', + contentType: 'git-graph', + view: { + view: { mode: 'git-graph' }, + file: { filePath: 'graph', relativePath: 'graph', language: 'plaintext' } + }, + groupId, + index: 0 + }) + const reply = await store.applyLayoutCommand( + { + type: 'movePaneToNewTab', + workspace: WORKTREE, + tabId: TAB, + leafId: LEAF_B, + groupId, + index: 0, + windowDetach: { ptyId: 'pty-b' } + }, + ['local'], + { visibility: withoutGitGraph } + ) + if (!reply.ok) { + throw new Error(reply.code) + } + const session = store.getWorkspaceSession() + expect(session.tabGroups?.[WORKTREE]?.[0]?.tabOrder).toEqual(['graph', reply.result.tabId, TAB]) + expect(session.terminalLayoutsByTabId[reply.result.tabId!]?.ptyIdsByLeafId).toEqual({ + [LEAF_B]: 'pty-b' + }) + expect(leafIds(session.terminalLayoutsByTabId[TAB]?.root)).toEqual([LEAF_A]) + }) + it('closes the pane in every partition holding the tab', async () => { const store = await storeWithSplitTab(['local', 'ssh:target-1']) diff --git a/src/main/persistence/terminal-topology/terminal-pane-admission.ts b/src/main/persistence/terminal-topology/terminal-pane-admission.ts index 08f4c4aed35..7650d86bfac 100644 --- a/src/main/persistence/terminal-topology/terminal-pane-admission.ts +++ b/src/main/persistence/terminal-topology/terminal-pane-admission.ts @@ -50,7 +50,10 @@ export type PaneLayoutChange = { } /** Runtime facts unavailable from stored layout. */ -export type LayoutLiveContext = Pick +export type LayoutLiveContext = Pick< + LayoutContext, + 'isBindingLive' | 'retiresBinding' | 'visibility' +> export function admitPaneToSession( hostId: ExecutionHostId, diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts index f09499cfaf8..fcd60494f9f 100644 --- a/src/main/persistence/terminal-topology/terminal-topology-commit.ts +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -156,7 +156,8 @@ export function applyLayout( ? commitWindowPaneDetach( hostIds, { ...command, windowDetach: command.windowDetach }, - context + context, + run ) : commitLayoutCommand(hostIds, command, context, run) ), diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index 50651ba49ee..cce67cdb63c 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -290,6 +290,9 @@ export function migrateWorktreeIdentity( if (state.ui?.explorerDisplayRootByWorktree) { changed = moveKey(state.ui.explorerDisplayRootByWorktree) || changed } + if (state.ui?.gitGraphFiltersByWorktree) { + changed = moveKey(state.ui.gitGraphFiltersByWorktree) || changed + } const showDotfiles = state.ui?.showDotfilesByWorktree if (showDotfiles) { changed = moveKey(showDotfiles) || changed diff --git a/src/main/providers/git-provider-contract.ts b/src/main/providers/git-provider-contract.ts index 6824677ca39..a5907dd7900 100644 --- a/src/main/providers/git-provider-contract.ts +++ b/src/main/providers/git-provider-contract.ts @@ -17,6 +17,7 @@ import type { GitStageWorktreeScope } from '../../shared/git/paths/git-stage-wor import type { RemoveWorktreeResult } from '../../shared/worktree/create-types' import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types' import type { + GitHistoryItemRef, GitHistoryOptions, GitHistoryResult } from '../../shared/git/history/git-history-types' @@ -35,6 +36,7 @@ export type IGitProvider = { area?: GitStagingArea ): Promise checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise + getHistoryRefs(worktreePath: string, query?: string): Promise getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> completeMerge(worktreePath: string): Promise<{ success: boolean; error?: string }> diff --git a/src/main/providers/local-git-provider.ts b/src/main/providers/local-git-provider.ts index 4493b0ec0a0..5a0b8b61f68 100644 --- a/src/main/providers/local-git-provider.ts +++ b/src/main/providers/local-git-provider.ts @@ -2,7 +2,7 @@ import { gitSyncForkDefaultBranch } from '../git/fork-sync' import type { GitRuntimeOptions } from '../git/git-runtime-options' import { checkIgnoredPaths } from '../git/source-control/check-ignored-paths' import { checkoutBranch, listLocalBranches } from '../git/checkout' -import { getHistory } from '../git/history' +import { getHistory, getHistoryRefs } from '../git/history' import { gitFastForward, gitFetch, gitPull, gitPullRebaseFromBase, gitPush } from '../git/remote' import { getRemoteCommitUrl, getRemoteFileUrl, isGitRepo } from '../git/repo' import { awaitWindowsHostGitEnvironmentReady, gitExecFileAsync } from '../git/runner' @@ -71,6 +71,7 @@ export function createLocalGitProvider(options: LocalGitProviderOptions = {}): L }), checkIgnoredPaths: (worktreePath, relativePaths) => checkIgnoredPaths(worktreePath, relativePaths, interactive), + getHistoryRefs: (worktreePath, query) => getHistoryRefs(worktreePath, query, interactive), getHistory: (worktreePath, historyOptions = {}) => getHistory(worktreePath, { ...historyOptions, ...interactive }), commit: (worktreePath, message) => commitChanges(worktreePath, message, interactive), diff --git a/src/main/runtime/integrations/git/orca-runtime-git.ts b/src/main/runtime/integrations/git/orca-runtime-git.ts index 900a15a5d5a..666f9e9f58c 100644 --- a/src/main/runtime/integrations/git/orca-runtime-git.ts +++ b/src/main/runtime/integrations/git/orca-runtime-git.ts @@ -9,6 +9,7 @@ export class RuntimeGitCommands { readonly getRuntimeGitStatus: RuntimeGitStatusCommands['getRuntimeGitStatus'] readonly getRuntimeGitSubmoduleStatus: RuntimeGitStatusCommands['getRuntimeGitSubmoduleStatus'] readonly checkRuntimeGitIgnoredPaths: RuntimeGitStatusCommands['checkRuntimeGitIgnoredPaths'] + readonly getRuntimeGitRefs: RuntimeGitStatusCommands['getRuntimeGitRefs'] readonly getRuntimeGitHistory: RuntimeGitStatusCommands['getRuntimeGitHistory'] readonly getRuntimeGitConflictOperation: RuntimeGitStatusCommands['getRuntimeGitConflictOperation'] readonly getRuntimeGitMutationInFlight: RuntimeGitStatusCommands['getRuntimeGitMutationInFlight'] @@ -55,6 +56,7 @@ export class RuntimeGitCommands { this.getRuntimeGitStatus = status.getRuntimeGitStatus.bind(status) this.getRuntimeGitSubmoduleStatus = status.getRuntimeGitSubmoduleStatus.bind(status) this.checkRuntimeGitIgnoredPaths = status.checkRuntimeGitIgnoredPaths.bind(status) + this.getRuntimeGitRefs = status.getRuntimeGitRefs.bind(status) this.getRuntimeGitHistory = status.getRuntimeGitHistory.bind(status) this.getRuntimeGitConflictOperation = status.getRuntimeGitConflictOperation.bind(status) this.getRuntimeGitMutationInFlight = status.getRuntimeGitMutationInFlight.bind(status) diff --git a/src/main/runtime/integrations/git/runtime-git-command-surface.ts b/src/main/runtime/integrations/git/runtime-git-command-surface.ts index 22f298f10ff..52997e92452 100644 --- a/src/main/runtime/integrations/git/runtime-git-command-surface.ts +++ b/src/main/runtime/integrations/git/runtime-git-command-surface.ts @@ -4,6 +4,7 @@ type RuntimeGitCommandName = | 'getRuntimeGitStatus' | 'getRuntimeGitSubmoduleStatus' | 'checkRuntimeGitIgnoredPaths' + | 'getRuntimeGitRefs' | 'getRuntimeGitHistory' | 'getRuntimeGitConflictOperation' | 'getRuntimeGitMutationInFlight' @@ -50,6 +51,7 @@ export function installRuntimeGitCommandSurface( getRuntimeGitStatus: commands.getRuntimeGitStatus.bind(commands), getRuntimeGitSubmoduleStatus: commands.getRuntimeGitSubmoduleStatus.bind(commands), checkRuntimeGitIgnoredPaths: commands.checkRuntimeGitIgnoredPaths.bind(commands), + getRuntimeGitRefs: commands.getRuntimeGitRefs.bind(commands), getRuntimeGitHistory: commands.getRuntimeGitHistory.bind(commands), getRuntimeGitConflictOperation: commands.getRuntimeGitConflictOperation.bind(commands), getRuntimeGitMutationInFlight: commands.getRuntimeGitMutationInFlight.bind(commands), diff --git a/src/main/runtime/integrations/git/runtime-git-status-commands.ts b/src/main/runtime/integrations/git/runtime-git-status-commands.ts index b31bdbf07fe..d7ef1944338 100644 --- a/src/main/runtime/integrations/git/runtime-git-status-commands.ts +++ b/src/main/runtime/integrations/git/runtime-git-status-commands.ts @@ -70,6 +70,11 @@ export class RuntimeGitStatusCommands { return requireRuntimeGitProvider(target).checkIgnoredPaths(target.worktree.path, relativePaths) } + async getRuntimeGitRefs(worktreeSelector: string, query?: string) { + const target = await resolveGatedGitTarget(this.host, worktreeSelector) + return requireRuntimeGitProvider(target).getHistoryRefs(target.worktree.path, query) + } + async getRuntimeGitHistory( worktreeSelector: string, options: GitHistoryOptions = {} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 8aac3448d11..4a03b2fe7fe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -260,7 +260,7 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { /** Apply a window command to the partitions that hold its tab. */ async applyLayoutCommand( command: Layout.WindowLayoutCommand, - run?: Pick + run?: Pick ): Promise { // Wait for live terminal placement before creating the first workspace surface. const placing = this.livePlacementSettledFor(command) diff --git a/src/main/runtime/rpc/git-graph-layout-projection.test.ts b/src/main/runtime/rpc/git-graph-layout-projection.test.ts new file mode 100644 index 00000000000..e3c7372a60e --- /dev/null +++ b/src/main/runtime/rpc/git-graph-layout-projection.test.ts @@ -0,0 +1,612 @@ +import { publishWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { withoutGitGraph } from '../../../shared/workspace-layout/workspace-layout-client-visibility' +import type { + CommandOf, + LayoutCommand +} from '../../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceLayoutModel } from '../../../shared/workspace-layout/workspace-layout-model' +import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' +import { expect, it } from 'vitest' +import { createGitGraphFrameProjection, projectGitGraphLayout } from './git-graph-layout-projection' +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + diffPublishedLayouts, + indexPublishedLayout +} from '../../../shared/workspace-layout/workspace-layout-records' + +function layout(): PublishedWorkspaceLayout { + return { + worktreeId: 'w', + editorFiles: [], + browserTabs: [], + tabs: ['a', 'graph', 'b', 'c'].map((id) => ({ + id, + entityId: id, + kind: id === 'graph' ? 'git-graph' : 'editor', + executionHostId: 'local', + createdAt: 0, + customTitle: null, + color: null + })), + groups: [ + { id: 'g1', tabIds: ['a', 'graph', 'b'] }, + { id: 'g2', tabIds: ['c'] } + ], + groupLayout: { + type: 'split', + direction: 'horizontal', + ratio: 0.3, + first: { type: 'leaf', groupId: 'g1' }, + second: { type: 'leaf', groupId: 'g2' } + } + } +} + +it('hides graph tabs in snapshots and later record frames while preserving sequence', () => { + const original = layout() + const project = createGitGraphFrameProjection() + expect( + project({ type: 'snapshot', seq: 1, workspaces: [{ key: 'w', layout: original }] }) + ).toMatchObject({ + workspaces: [ + { + layout: { + groups: [ + { id: 'g1', tabIds: ['a', 'b'] }, + { id: 'g2', tabIds: ['c'] } + ] + } + } + ] + }) + const after = { + ...original, + groups: [ + { id: 'g1', tabIds: ['graph'] }, + { id: 'g2', tabIds: ['a', 'b', 'c'] } + ] + } + const change = diffPublishedLayouts( + 'w', + indexPublishedLayout(original), + indexPublishedLayout(after), + after + )! + const frame = project({ type: 'records', epoch: 'e', seq: 2, workspaces: [change] }) + expect(frame).toMatchObject({ + type: 'records', + epoch: 'e', + seq: 2, + workspaces: [ + { + workspace: { + groups: [{ id: 'g2', tabIds: ['a', 'b', 'c'] }], + groupLayout: { type: 'leaf', groupId: 'g2' } + } + } + ] + }) + expect(projectGitGraphLayout(after).tabs.map((tab) => tab.id)).toEqual(['a', 'b', 'c']) +}) + +it('passes graph-free record deltas through unchanged and preserves existing empty groups', () => { + const original = layout() + const graphFree = { ...original, tabs: original.tabs.filter((tab) => tab.kind !== 'git-graph') } + const project = createGitGraphFrameProjection() + project({ type: 'workspace', key: 'w', seq: 1, layout: graphFree }) + const change = { + key: 'w', + change: 'records' as const, + workspace: { ...graphFree, groups: [{ id: 'g1', tabIds: ['a', 'b', 'c'] }] } + } + const frame = project({ type: 'records', epoch: 'e', seq: 2, workspaces: [change] }) + expect(frame.type === 'records' && frame.workspaces[0]).toBe(change) + original.groups.push({ id: 'empty', tabIds: [] }) + expect(projectGitGraphLayout(original).groups).toContainEqual({ id: 'empty', tabIds: [] }) +}) + +function canonical(original = layout()): WorkspaceLayoutModel { + return { + hostId: 'local', + records: {}, + legacy: { terminalRowOwners: {} }, + workspaces: { + w: { + worktreeId: 'w', + tabs: original.tabs.map(({ kind, ...tab }) => { + if (kind === 'terminal') { + throw new Error('Expected a content fixture') + } + return { ...tab, kind } + }), + groups: original.groups.map(({ id, tabIds }) => ({ id, tabOrder: tabIds })), + groupLayout: original.groupLayout + } + } + } +} + +function applyClient(command: LayoutCommand, model = canonical()) { + return applyLayoutCommand(model, command, { + visibility: withoutGitGraph, + mintId: () => 'minted', + mintLeafId: () => 'leaf', + now: () => 0 + }) +} + +const opens: CommandOf< + | 'openEditorTab' + | 'createTerminalTab' + | 'openSimulatorTab' + | 'openBrowserTab' + | 'openAgentSessionTab' +>[] = [ + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/new', + tabId: 'new', + index: 1 + }, + { type: 'createTerminalTab', workspace: 'w', tabId: 'new', leafId: 'leaf', index: 1 }, + { type: 'openSimulatorTab', workspace: 'w', tabId: 'new', index: 1 }, + { type: 'openBrowserTab', workspace: 'w', tabId: 'new', index: 1 }, + { + type: 'openAgentSessionTab', + workspace: 'w', + tabId: 'new', + sessionId: 'session', + agent: 'codex', + index: 1 + } +] + +it.each(opens)( + '$type resolves a visible creation gap without moving the hidden graph', + (command) => { + const opened = applyClient({ ...command, groupId: 'g1' }) + expect(opened.ok).toBe(true) + if (!opened.ok) { + throw new Error(opened.code) + } + expect(opened.model.workspaces.w!.groups).toEqual([ + { id: 'g1', tabOrder: ['a', 'graph', 'new', 'b'] }, + { id: 'g2', tabOrder: ['c'] } + ]) + expect( + projectGitGraphLayout(publishWorkspaceLayout(opened.model.workspaces.w!, 'local')).groups + ).toEqual([ + { id: 'g1', tabIds: ['a', 'new', 'b'] }, + { id: 'g2', tabIds: ['c'] } + ]) + expect(opened.effects).toEqual({ + stopPtyIds: [], + startPaneKeys: command.type === 'createTerminalTab' ? ['new:leaf'] : [] + }) + expect(opened.model.workspaces.w!.tabs.find((tab) => tab.id === 'graph')).toMatchObject({ + kind: 'git-graph' + }) + } +) + +it('reopens at the visible index and accounts for preview replacement before placing', () => { + const model = canonical() + model.workspaces.w!.tabs.find((tab) => tab.id === 'a')!.isPreview = true + const opened = applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/new', + tabId: 'new', + preview: true, + groupId: 'g1', + index: 1 + }, + model + ) + expect(opened.ok && opened.model.workspaces.w!.groups[0]!.tabOrder).toEqual(['graph', 'b', 'new']) + model.workspaces.w!.tabs.find((tab) => tab.id === 'a')!.isPreview = false + model.workspaces.w!.tabs.find((tab) => tab.id === 'graph')!.isPreview = true + const hiddenPreview = applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/new', + tabId: 'new', + preview: true, + groupId: 'g1', + index: 1 + }, + model + ) + expect(hiddenPreview.ok && hiddenPreview.model.workspaces.w!.groups[0]!.tabOrder).toEqual([ + 'a', + 'graph', + 'new', + 'b' + ]) + const root = canonical() + root.workspaces.w!.groups = [{ id: 'g1', tabOrder: ['graph', 'a', 'b', 'c'] }] + root.workspaces.w!.groupLayout = { type: 'leaf', groupId: 'g1' } + const closed = applyClient({ type: 'closeTabs', workspace: 'w', tabIds: ['b'] }, root) + if (!closed.ok) { + throw new Error(closed.code) + } + const reopened = applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: 'b', + tabId: 'b', + groupId: 'g1', + index: 1 + }, + closed.model + ) + expect(reopened.ok && reopened.model.workspaces.w!.groups[0]!.tabOrder).toEqual([ + 'graph', + 'a', + 'b', + 'c' + ]) +}) + +it.each([ + { index: 0, afterTabId: 'a', expected: ['a', 'graph', 'b'] }, + { index: 1, afterTabId: null, expected: ['b', 'a', 'graph'] }, + { index: 1, afterTabId: 'gone', expected: ['a', 'graph', 'b'] } +])('moves using anchor precedence: $afterTabId', ({ index, afterTabId, expected }) => { + const moved = applyClient({ + type: 'moveTab', + workspace: 'w', + tabId: 'b', + toGroupId: 'g1', + index, + afterTabId + }) + expect(moved.ok && moved.model.workspaces.w!.groups[0]!.tabOrder).toEqual(expected) +}) + +it('gives creation indexes precedence over anchors and retains pin-aware omitted placement', () => { + const model = canonical() + model.workspaces.w!.tabs.find((tab) => tab.id === 'a')!.isPinned = true + const command = { + type: 'openSimulatorTab', + workspace: 'w', + tabId: 'new', + groupId: 'g1', + afterTabId: 'b' + } as const + const indexed = applyClient({ ...command, index: 0 }, model) + expect(indexed.ok && indexed.model.workspaces.w!.groups[0]!.tabOrder).toEqual([ + 'new', + 'a', + 'graph', + 'b' + ]) + const omitted = applyClient({ ...command, afterTabId: 'a' }, model) + expect(omitted.ok && omitted.model.workspaces.w!.groups[0]!.tabOrder).toEqual([ + 'a', + 'graph', + 'new', + 'b' + ]) +}) + +it.each([undefined, 'older-root', 'g2'])( + 'opens in a projected-empty workspace, requested group %s', + (groupId) => { + const model = canonical() + model.workspaces.w!.tabs = model.workspaces.w!.tabs.filter((tab) => tab.id === 'graph') + model.workspaces.w!.groups = [{ id: 'g2', tabOrder: ['graph'] }] + model.workspaces.w!.groupLayout = { type: 'leaf', groupId: 'g2' } + const opened = applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/file', + tabId: 'file', + groupId + }, + model + ) + expect(opened.ok && opened.model.workspaces.w!.groups).toEqual([ + { id: 'g2', tabOrder: ['graph', 'file'] } + ]) + const split = applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/file', + tabId: 'file', + groupId: 'new-group', + split: { besideGroupId: 'older-root', direction: 'right' } + }, + model + ) + expect(split.ok && split.model.workspaces.w!.groups).toEqual([ + { id: 'g2', tabOrder: ['graph'] }, + { id: 'new-group', tabOrder: ['file'] } + ]) + } +) + +it('uses visible groups for implicit opens and refuses stale split anchors', () => { + const model = canonical() + model.workspaces.w!.groups = [ + { id: 'hidden', tabOrder: ['graph'] }, + { id: 'g1', tabOrder: ['a', 'b', 'c'] } + ] + const implicit = applyClient( + { type: 'openEditorTab', workspace: 'w', contentType: 'editor', fileId: '/new', tabId: 'new' }, + model + ) + expect(implicit.ok && implicit.model.workspaces.w!.groups).toEqual([ + { id: 'hidden', tabOrder: ['graph'] }, + { id: 'g1', tabOrder: ['a', 'b', 'c', 'new'] } + ]) + const defaults = applyClient( + { type: 'applyDefaultTabs', workspace: 'w', tabs: [{ tabId: 'default', leafId: 'leaf' }] }, + model + ) + expect(defaults.ok && defaults.model.workspaces.w!.groups).toEqual([ + { id: 'hidden', tabOrder: ['graph'] }, + { id: 'g1', tabOrder: ['a', 'b', 'c', 'default'] } + ]) + expect(defaults.ok && defaults.effects).toEqual({ + stopPtyIds: [], + startPaneKeys: ['default:leaf'] + }) + expect( + applyClient( + { + type: 'openEditorTab', + workspace: 'w', + contentType: 'editor', + fileId: '/new', + tabId: 'new', + groupId: 'proposed', + split: { besideGroupId: 'stale', direction: 'right' } + }, + model + ) + ).toEqual({ ok: false, code: 'group_not_found' }) +}) + +it('translates split paths and identities together while retaining hidden branches', () => { + const model = canonical() + const workspace = model.workspaces.w! + workspace.groups = [ + { id: 'hidden', tabOrder: ['graph'] }, + { id: 'g1', tabOrder: ['a', 'b'] }, + { id: 'g2', tabOrder: ['c'] } + ] + workspace.groupLayout = { + type: 'split', + direction: 'vertical', + ratio: 0.2, + first: { type: 'leaf', groupId: 'hidden' }, + second: workspace.groupLayout! + } + const resized = applyClient( + { + type: 'setGroupRatios', + workspace: 'w', + ratios: [ + { path: '', ratio: 0.7, split: { direction: 'horizontal', first: 'g1', second: 'g2' } } + ] + }, + model + ) + expect(resized.ok && resized.model.workspaces.w!.groupLayout).toEqual({ + ...workspace.groupLayout, + second: { ...layout().groupLayout, ratio: 0.7 } + }) + workspace.groupLayout = { + type: 'split', + direction: 'horizontal', + ratio: 0.3, + first: { + type: 'split', + direction: 'vertical', + ratio: 0.2, + first: { type: 'leaf', groupId: 'hidden' }, + second: { type: 'leaf', groupId: 'g1' } + }, + second: { type: 'leaf', groupId: 'g2' } + } + const identities = applyClient( + { + type: 'setGroupRatios', + workspace: 'w', + ratios: [ + { path: '', ratio: 0.7, split: { direction: 'horizontal', first: 'g1', second: 'g2' } } + ] + }, + model + ) + expect(identities.ok && identities.model.workspaces.w!.groupLayout).toEqual({ + ...workspace.groupLayout, + ratio: 0.7 + }) + expect( + applyClient( + { + type: 'setGroupRatios', + workspace: 'w', + ratios: [ + { path: '', ratio: 0.7, split: { direction: 'vertical', first: 'stale', second: 'g2' } } + ] + }, + model + ) + ).toEqual({ ok: false, code: 'group_set_changed' }) +}) + +it('splits a lone visible tab without moving its hidden sibling and refuses splitting it off itself', () => { + const model = canonical() + model.workspaces.w!.groups = [ + { id: 'g1', tabOrder: ['a', 'graph'] }, + { id: 'g2', tabOrder: ['b', 'c'] } + ] + expect( + applyClient( + { type: 'splitGroup', workspace: 'w', tabId: 'a', besideGroupId: 'g1', direction: 'right' }, + model + ) + ).toEqual({ ok: false, code: 'invalid_params' }) + const split = applyClient( + { type: 'splitGroup', workspace: 'w', tabId: 'a', besideGroupId: 'g2', direction: 'right' }, + model + ) + expect(split.ok && split.model.workspaces.w!.groups).toEqual([ + { id: 'g1', tabOrder: ['graph'] }, + { id: 'g2', tabOrder: ['b', 'c'] }, + { id: 'minted', tabOrder: ['a'] } + ]) +}) + +it('keeps identity commands addressed by stable tab ids', () => { + const renamed = applyClient({ + type: 'renameTab', + workspace: 'w', + tabId: 'b', + kind: 'custom', + title: 'Renamed' + }) + expect( + renamed.ok && renamed.model.workspaces.w!.tabs.find((tab) => tab.id === 'b')?.customTitle + ).toBe('Renamed') + const closed = applyClient({ type: 'closeTabs', workspace: 'w', tabIds: ['a', 'b'] }) + expect(closed.ok && closed.model.workspaces.w!.groups).toEqual([ + { id: 'g1', tabOrder: ['graph'] }, + { id: 'g2', tabOrder: ['c'] } + ]) +}) + +it('places a detached pane at a visible gap and leaves the hidden graph untouched', () => { + const model = canonical() + const workspace = model.workspaces.w! + workspace.tabs = workspace.tabs.filter((tab) => tab.id !== 'c') + workspace.tabs.push({ + id: 'c', + entityId: 'c', + kind: 'terminal', + createdAt: 0, + customTitle: null, + color: null, + terminal: { defaultTitle: 'Terminal 1' }, + panes: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: 'one' }, + second: { type: 'leaf', leafId: 'two' } + } + } + }) + const detached = applyClient( + { + type: 'movePaneToNewTab', + workspace: 'w', + tabId: 'c', + leafId: 'one', + groupId: 'g1', + index: 1 + }, + model + ) + expect(detached.ok && detached.model.workspaces.w!.groups).toEqual([ + { id: 'g1', tabOrder: ['a', 'graph', 'minted', 'b'] }, + { id: 'g2', tabOrder: ['c'] } + ]) + expect(detached.ok && detached.effects).toEqual({ stopPtyIds: [], startPaneKeys: [] }) + const implicit = applyClient( + { type: 'movePaneToNewTab', workspace: 'w', tabId: 'c', leafId: 'one' }, + model + ) + expect(implicit.ok && implicit.model.workspaces.w!.groups).toEqual([ + { id: 'g1', tabOrder: ['a', 'graph', 'b'] }, + { id: 'g2', tabOrder: ['c', 'minted'] } + ]) +}) + +it('projects graph add/remove around binding-only deltas without losing visible updates', () => { + const original = layout() + original.tabs = original.tabs.map((tab) => + tab.id === 'c' + ? { + ...tab, + kind: 'terminal', + terminal: { + defaultTitle: 'Terminal 1', + root: { type: 'leaf', leafId: 'one' }, + panes: [{ leafId: 'one', ptyId: 'initial' }] + } + } + : tab + ) + const graphFree = { + ...original, + tabs: original.tabs.filter((tab) => tab.id !== 'graph'), + groups: [ + { id: 'g1', tabIds: ['a', 'b'] }, + { id: 'g2', tabIds: ['c'] } + ] + } + const project = createGitGraphFrameProjection() + project({ type: 'workspace', key: 'w', seq: 1, layout: graphFree }) + const binding = { + key: 'w', + change: 'records' as const, + bindings: { set: [{ leafId: 'one', ptyId: 'pty' }] } + } + const before = project({ type: 'records', epoch: 'e', seq: 2, workspaces: [binding] }) + expect(before.type === 'records' && before.workspaces[0]).toBe(binding) + const added = project({ + type: 'records', + epoch: 'e', + seq: 3, + workspaces: [ + diffPublishedLayouts( + 'w', + indexPublishedLayout(graphFree), + indexPublishedLayout(original), + original + )! + ] + }) + expect(added).toMatchObject({ workspaces: [] }) + const whileHidden = project({ + type: 'records', + epoch: 'e', + seq: 4, + workspaces: [{ ...binding, bindings: { set: [{ leafId: 'one', ptyId: 'next' }] } }] + }) + expect(whileHidden).toMatchObject({ + workspaces: [{ key: 'w', bindings: { set: [{ leafId: 'one', ptyId: 'next' }] } }] + }) + const removed = project({ + type: 'records', + epoch: 'e', + seq: 5, + workspaces: [ + diffPublishedLayouts( + 'w', + indexPublishedLayout(original), + indexPublishedLayout(graphFree), + graphFree + )! + ] + }) + expect(removed).toMatchObject({ workspaces: [] }) + const after = project({ type: 'records', epoch: 'e', seq: 6, workspaces: [binding] }) + expect(after.type === 'records' && after.workspaces[0]).toBe(binding) +}) diff --git a/src/main/runtime/rpc/git-graph-layout-projection.ts b/src/main/runtime/rpc/git-graph-layout-projection.ts new file mode 100644 index 00000000000..4d4eaa45273 --- /dev/null +++ b/src/main/runtime/rpc/git-graph-layout-projection.ts @@ -0,0 +1,104 @@ +import { GIT_GRAPH_TAB_CAPABILITY } from '../../../shared/source-control/runtime/git-graph-capabilities' +import { visibleGroupTree } from '../../../shared/workspace-layout/workspace-layout-client-visibility' +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + applyPublishedLayoutRecordChanges, + splitPublishedLayout, + joinPublishedLayout, + type PublishedLayoutRecords, + diffPublishedLayouts, + indexPublishedLayout +} from '../../../shared/workspace-layout/workspace-layout-records' +import type { WorkspaceLayoutStreamFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +export function readsGitGraph(capabilities?: readonly string[]): boolean { + return capabilities?.includes(GIT_GRAPH_TAB_CAPABILITY) === true +} + +export function projectGitGraphLayout(layout: PublishedWorkspaceLayout): PublishedWorkspaceLayout { + const hidden = new Set(layout.tabs.filter((tab) => tab.kind === 'git-graph').map((tab) => tab.id)) + if (!hidden.size) { + return layout + } + const hiddenFiles = new Set( + layout.tabs.filter((tab) => hidden.has(tab.id)).map((tab) => tab.entityId) + ) + const groups = layout.groups + .filter((group) => !group.tabIds.length || group.tabIds.some((id) => !hidden.has(id))) + .map((group) => ({ ...group, tabIds: group.tabIds.filter((id) => !hidden.has(id)) })) + return { + ...layout, + tabs: layout.tabs.filter((tab) => !hidden.has(tab.id)), + editorFiles: layout.editorFiles.filter((file) => !hiddenFiles.has(file.filePath)), + groups, + groupLayout: visibleGroupTree(layout.groupLayout, new Set(groups.map((group) => group.id))) + } +} + +// Re-diff whole projected layouts: a delta alone may omit the group a hidden tab made empty. +export function createGitGraphFrameProjection() { + const presence = new WeakMap() + const hasGraph = (records: PublishedLayoutRecords | undefined): boolean => { + if (!records) { + return false + } + const cached = presence.get(records.tabs) + if (cached !== undefined) { + return cached + } + const found = records.tabs.some((tab) => tab.kind === 'git-graph') + presence.set(records.tabs, found) + return found + } + const layouts = new Map() + return (frame: WorkspaceLayoutStreamFrame): WorkspaceLayoutStreamFrame => { + switch (frame.type) { + case 'end': + layouts.clear() + return frame + case 'removed': + layouts.delete(frame.key) + return frame + case 'snapshot': + layouts.clear() + return { + ...frame, + workspaces: frame.workspaces.map(({ key, layout }) => { + layouts.set(key, splitPublishedLayout(layout)) + return { key, layout: projectGitGraphLayout(layout) } + }) + } + case 'workspace': + layouts.set(frame.key, splitPublishedLayout(frame.layout)) + return { ...frame, layout: projectGitGraphLayout(frame.layout) } + case 'records': + return { + ...frame, + workspaces: frame.workspaces.flatMap((change) => { + const before = layouts.get(change.key) + if (change.change === 'removed') { + layouts.delete(change.key) + return [change] + } + const after = + change.change === 'layout' + ? splitPublishedLayout(change.layout) + : applyPublishedLayoutRecordChanges(before!, change) + layouts.set(change.key, after) + if (!hasGraph(before) && !hasGraph(after)) { + return [change] + } + const projected = projectGitGraphLayout(joinPublishedLayout(after)) + const diff = diffPublishedLayouts( + change.key, + before + ? indexPublishedLayout(projectGitGraphLayout(joinPublishedLayout(before))) + : null, + indexPublishedLayout(projected), + projected + ) + return diff ? [diff] : [] + }) + } + } + } +} diff --git a/src/main/runtime/rpc/methods/source-control/git/git.ts b/src/main/runtime/rpc/methods/source-control/git/git.ts index c8e4a03e823..8580f6853ba 100644 --- a/src/main/runtime/rpc/methods/source-control/git/git.ts +++ b/src/main/runtime/rpc/methods/source-control/git/git.ts @@ -13,6 +13,7 @@ import { GitResolveConflict, GitForkSync, GitHistory, + GitRefs, GitPush, GitRebaseFromBase, GitRemoteCommitUrl, @@ -74,6 +75,12 @@ export const GIT_METHODS = [ handler: async (params, { runtime }) => runtime.getRuntimeGitSubmoduleStatus(params.worktree, params.submodulePath, params.area) }), + defineMethod({ + name: 'git.refs', + permission: 'workspace', + params: GitRefs, + handler: async (params, { runtime }) => runtime.getRuntimeGitRefs(params.worktree, params.query) + }), defineMethod({ name: 'git.history', permission: 'workspace', @@ -83,6 +90,8 @@ export const GIT_METHODS = [ limit: params.limit, baseRef: params.baseRef, branchOnly: params.branchOnly, + refs: params.refs, + historyToken: params.historyToken, skip: params.skip }) }), diff --git a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts index 001ad7f1bd0..6ccc79fafdf 100644 --- a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout-apply.test.ts @@ -4,7 +4,7 @@ import type { ExecutionHostId } from '../../../../../shared/execution-host' import type { LayoutApplyOutcome } from '../../../../../shared/workspace-layout/workspace-layout-command-types' import type { Tab } from '../../../../../shared/tab-types' import type { RuntimeStore } from '../../../runtime-store-contract' -import { RUNTIME_CAPABILITIES } from '../../../../../shared/protocol-version' +import { withoutGitGraph } from '../../../../../shared/workspace-layout/workspace-layout-client-visibility' import { MOBILE_RPC_METHOD_ALLOWLIST } from '../../server/runtime-rpc-mobile-method-allowlist' import { OrcaRuntimeService } from '../../../orca-runtime' import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../../core' @@ -90,7 +90,6 @@ describe('layout.apply', () => { for (const name of ['layout.apply', 'layout.reopenHeldPanes', 'layout.reportView']) { expect(names, name).not.toContain(name) } - expect(RUNTIME_CAPABILITIES.some((capability) => capability.startsWith('layout'))).toBe(false) expect(MOBILE_RPC_METHOD_ALLOWLIST.has('layout.apply')).toBe(false) }) @@ -110,7 +109,10 @@ describe('layout.apply', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime from its context. await apply.handler(params, { runtime } as RpcContext) - expect(applyLayoutCommand).toHaveBeenCalledWith({ ...SPLIT, newLeafId }) + expect(applyLayoutCommand).toHaveBeenCalledWith( + { ...SPLIT, newLeafId }, + { visibility: withoutGitGraph } + ) expect(apply.params?.parse({ command: { ...SPLIT, id: 'window-id' } })).toEqual({ command: SPLIT }) diff --git a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.test.ts b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.test.ts index ebf66b1b93e..72587b6681c 100644 --- a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.test.ts @@ -27,8 +27,14 @@ function harness(epoch?: string) { publish = listener return { snapshot: [ - { key: 'a', layout: { worktreeId: 'a' } }, - { key: 'b', layout: { worktreeId: 'b' } } + { + key: 'a', + layout: { worktreeId: 'a', tabs: [], groups: [], editorFiles: [], browserTabs: [] } + }, + { + key: 'b', + layout: { worktreeId: 'b', tabs: [], groups: [], editorFiles: [], browserTabs: [] } + } ], seq: 4, ...(epoch ? { epoch } : {}), @@ -66,7 +72,12 @@ describe('layout.subscribe', () => { { type: 'snapshot', subscriptionId: expect.any(String), - workspaces: [{ key: 'b', layout: { worktreeId: 'b' } }], + workspaces: [ + { + key: 'b', + layout: { worktreeId: 'b', tabs: [], groups: [], editorFiles: [], browserTabs: [] } + } + ], seq: 4 } ]) diff --git a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.ts index ab6a114aec4..d56c9ef9d8a 100644 --- a/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout/workspace-layout.ts @@ -1,3 +1,5 @@ +import { withoutGitGraph } from '../../../../../shared/workspace-layout/workspace-layout-client-visibility' +import { readsGitGraph, createGitGraphFrameProjection } from '../../git-graph-layout-projection' // Paired desktops use layout read/stream/apply only when the server offers layout.intents.v1; mobile is excluded. import { @@ -23,9 +25,11 @@ export const WORKSPACE_LAYOUT_METHODS = [ name: 'layout.subscribe', permission: 'workspace', params: LayoutSubscribeParams, - handler: async (params, { runtime, connectionId, signal }, emitFrame) => { + handler: async (params, { runtime, connectionId, signal, clientCapabilities }, emitFrame) => { // Keep emitted frames checked against the client contract. - const emit = (frame: WorkspaceLayoutStreamFrame): void => emitFrame(frame) + const project = createGitGraphFrameProjection() + const emit = (frame: WorkspaceLayoutStreamFrame): void => + emitFrame(readsGitGraph(clientCapabilities) ? frame : project(frame)) const keys = params?.workspaces const wanted = (key: string) => !Array.isArray(keys) || keys.includes(key) await new Promise((resolve) => { @@ -97,7 +101,12 @@ export const LAYOUT_APPLY_METHODS = [ name: 'layout.apply', permission: 'workspace', params: LayoutApplyParams, - handler: async (params, { runtime }) => runtime.applyLayoutCommand(params.command) + handler: async (params, { runtime, clientCapabilities }) => { + return runtime.applyLayoutCommand( + params.command, + readsGitGraph(clientCapabilities) ? undefined : { visibility: withoutGitGraph } + ) + } }), // Reopen held panes; main's frames, not the reply, show their start. defineMethod({ diff --git a/src/main/runtime/terminal/layout/runtime-layout-apply.ts b/src/main/runtime/terminal/layout/runtime-layout-apply.ts index 1eea06ee9a2..5427c6da54e 100644 --- a/src/main/runtime/terminal/layout/runtime-layout-apply.ts +++ b/src/main/runtime/terminal/layout/runtime-layout-apply.ts @@ -52,7 +52,7 @@ export async function applyWindowLayoutCommand( return { ok: false, code: 'wsl_move_in_progress' } } if (command.type === 'moveTabToWorkspace') { - return moveTabToWorkspace(store, hostIdsFor, command) + return moveTabToWorkspace(store, hostIdsFor, command, run) } if (command.type === 'renameEditorFiles' && command.alsoIn) { return renameEditorFilesInWorkspaces(store, hostIdsFor, command) @@ -187,7 +187,8 @@ async function renameTabs( async function moveTabToWorkspace( store: RuntimeStore, hostIdsFor: (worktreeId: string, tabId: string | null) => ExecutionHostId[], - command: CommandOf<'moveTabToWorkspace'> + command: CommandOf<'moveTabToWorkspace'>, + run?: LayoutCommandRunOptions ): Promise { const [from] = hostIdsFor(command.workspace, null) const [to] = hostIdsFor(command.toWorkspace, null) @@ -202,12 +203,16 @@ async function moveTabToWorkspace( if (!store.moveTabToWorkspaceAcrossPartitions) { throw new Error('layout_store_unavailable') } - return store.moveTabToWorkspaceAcrossPartitions(command, from, to) + return run + ? store.moveTabToWorkspaceAcrossPartitions(command, from, to, run) + : store.moveTabToWorkspaceAcrossPartitions(command, from, to) } if (!store.applyLayoutCommand) { throw new Error('layout_store_unavailable') } - return store.applyLayoutCommand(command, [from]) + return run + ? store.applyLayoutCommand(command, [from], run) + : store.applyLayoutCommand(command, [from]) } /** Rename disk-move paths across partitions in one durable write; paired-server ownership refuses the whole operation. */ diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index ffcfba9cf11..6cff7e28800 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -8,6 +8,7 @@ import type { ConflictResolution } from '../../shared/source-control/listing/sou import type { GitPushTarget } from '../../shared/worktree/types' import type { GitStageWorktreeScope } from '../../shared/git/paths/git-stage-worktree-scope' import type { + GitHistoryItemRef, GitHistoryOptions, GitHistoryResult } from '../../shared/git/history/git-history-types' @@ -49,6 +50,11 @@ export const gitApi = { ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => ipcRenderer.invoke('git:appendGitignore', args), + refs: (args: { + worktreePath: string + connectionId?: string + query?: string + }): Promise => ipcRenderer.invoke('git:refs', args), history: ( args: { worktreePath: string diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts index a7ee8ee5564..7fa8d767a28 100644 --- a/src/preload/api/git-inspection-api.ts +++ b/src/preload/api/git-inspection-api.ts @@ -12,6 +12,7 @@ import type { } from '../../shared/git/status/git-status-types' import type { GitPushTarget } from '../../shared/worktree/types' import type { + GitHistoryItemRef, GitHistoryOptions, GitHistoryResult } from '../../shared/git/history/git-history-types' @@ -56,6 +57,11 @@ export type GitInspectionApi = { connectionId?: string }) => Promise findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise + refs: (args: { + worktreePath: string + connectionId?: string + query?: string + }) => Promise history: ( args: { worktreePath: string diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 7ab29dd0472..c0c14a3fd59 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -121,6 +121,7 @@ export function usePersistedUIWriter(): void { const activeView = useAppStore((s) => s.activeView) const ui = useAppStore( useShallow((s): PersistedUIWriteBaseline => ({ + gitGraphFiltersByWorktree: s.gitGraphFiltersByWorktree, sidebarWidth: s.sidebarWidth, sidebarOpen: s.sidebarOpen, rightSidebarOpen: s.rightSidebarOpen, diff --git a/src/renderer/src/components/cmd-j/quick-actions.ts b/src/renderer/src/components/cmd-j/quick-actions.ts index 1c10ebc4be5..2925bb5c6cb 100644 --- a/src/renderer/src/components/cmd-j/quick-actions.ts +++ b/src/renderer/src/components/cmd-j/quick-actions.ts @@ -1,4 +1,6 @@ +import { gitGraphSupport, openGitGraph } from '@/lib/git-graph-tab' import { + GitGraph, FileText, FolderPlus, Globe, @@ -33,6 +35,7 @@ export type CmdJQuickAction = { icon: LucideIcon verbKeywords: string[] /** Query-bound actions must still match the live input when selected. */ + disabled?: boolean sourceQuery?: string isAvailable: (ctx: CmdJQuickActionContext) => CmdJQuickActionAvailability run: (ctx: CmdJQuickActionContext) => Promise @@ -78,6 +81,25 @@ export async function runBrowserWorkspaceAction( // Keep context-heavy imports in Settings, where explanatory UI and failure states fit. export const getCmdJQuickActions = createLocalizedCatalog((): CmdJQuickAction[] => [ + { + id: 'git-graph', + kind: 'action', + title: translate('gitGraph.title', 'Git Graph'), + description: '', + icon: GitGraph, + verbKeywords: ['git graph', 'commit history', 'branches'], + isAvailable: (ctx) => + ctx.activeWorktreeId && gitGraphSupport(ctx.activeWorktreeId) === true + ? { available: true } + : { available: false, reason: 'loading' }, + run: async (ctx) => { + if (!ctx.activeWorktreeId || gitGraphSupport(ctx.activeWorktreeId) !== true) { + return { status: 'unavailable', reason: 'loading' } + } + await openGitGraph(ctx.activeWorktreeId) + return { status: 'ok' } + } + }, { id: 'new-browser-tab', kind: 'action', diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 2604ae5273a..2bd9c1bcff8 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -1,3 +1,4 @@ +import { GitGraphTab } from './git-graph/git-graph-tab' import { useAppStore } from '@/store' import type { ActivePluginCustomEditor } from '@/store/plugin-custom-editors' import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor' @@ -131,6 +132,10 @@ export function EditorContent({ activeFile.diffSource === 'combined-branch' || activeFile.diffSource === 'combined-commit') + if (activeFile.mode === 'git-graph') { + return + } + if (activeFile.mode === 'check-details') { const checkRunDetails = activeFile.checkRunDetails if (!checkRunDetails) { diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index b3bc21547f6..e6ef2c0454b 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -34,6 +34,14 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState } } + if (file.mode === 'git-graph') { + return { + copyText: null, + copyToastLabel: '', + pathLabel: translate('gitGraph.title', 'Git Graph'), + pathTitle: translate('gitGraph.title', 'Git Graph') + } + } if (file.mode === 'check-details') { const label = file.checkRunDetails?.check.name ?? diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 53f885b1da2..1888ca6642a 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -42,6 +42,9 @@ export function getEditorDisplayLabel( return translate('auto.components.editor.editorLabels.conflictReview', 'Conflict Review') } + if (file.mode === 'git-graph') { + return translate('gitGraph.title', 'Git Graph') + } if (file.mode === 'check-details') { return file.checkRunDetails?.check.name ?? getBaseLabel(file, variant) } diff --git a/src/renderer/src/components/editor/git-graph/git-graph-details.tsx b/src/renderer/src/components/editor/git-graph/git-graph-details.tsx new file mode 100644 index 00000000000..e568335ed99 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-details.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react' +import { Copy } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { + GitHistoryCommitFiles, + type GitHistoryCommitFilesState +} from '@/components/right-sidebar/source-control/sync/git-history-commit-files' +import { formatGitHistoryTimestamp } from '@/components/right-sidebar/source-control/sync/git-history-format' +import type { useGitHistoryCommitActions } from '@/components/right-sidebar/source-control/sync/use-git-history-commit-actions' +import type { GitHistoryItem } from '../../../../../shared/git/history/git-history-types' +import { translate } from '@/i18n/i18n' + +export function GitGraphDetails({ + item, + actions, + onParent +}: { + item: GitHistoryItem + actions: ReturnType + onParent: (id: string) => void +}) { + const [files, setFiles] = useState({ status: 'loading' }) + const loadFiles = actions.loadCommitFiles + useEffect(() => { + let live = true + setFiles({ status: 'loading' }) + void loadFiles(item) + .then((entries) => { + if (live) { + setFiles({ status: 'ready', entries }) + } + }) + .catch((cause) => { + if (live) { + setFiles({ status: 'error', error: String(cause) }) + } + }) + return () => { + live = false + } + }, [item, loadFiles]) + const body = item.message.startsWith(item.subject) + ? item.message.slice(item.subject.length).trim() + : item.message + return ( +
+
+

{item.subject}

+ {body &&

{body}

} +
+
+ {item.author} + {item.authorEmail ? ` <${item.authorEmail}>` : ''} +
+
{formatGitHistoryTimestamp(item.timestamp)}
+
+
+ {item.id} + + + + + {translate('gitGraph.copyHash', 'Copy commit hash')} + +
+ {!!item.parentIds.length && ( +
+ + {translate('gitGraph.parents', 'Parents')} + + {item.parentIds.map((id) => ( + + ))} +
+ )} +
+
+ {translate('gitGraph.files', 'Files')} +
+ actions.openCommitFile(item, entry, event)} + onOpenAll={() => void actions.openHistoryCommitDiff(item)} + /> +
+ ) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-filter.tsx b/src/renderer/src/components/editor/git-graph/git-graph-filter.tsx new file mode 100644 index 00000000000..bda613e2746 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-filter.tsx @@ -0,0 +1,225 @@ +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { getRuntimeGitRefs } from '@/runtime/runtime-git-status-client' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { GIT_HISTORY_REFS_CAPABILITY } from '../../../../../shared/source-control/runtime/git-graph-capabilities' +import { gitGraphScope, useGitGraphScope } from '@/lib/git-graph-tab' +import { translate } from '@/i18n/i18n' +import type { + GitHistoryItemRef, + GitHistoryRefs +} from '../../../../../shared/git/history/git-history-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' + +type GitGraphFilterProps = { + worktreeId: string + executionHostId?: ExecutionHostId + filter: GitHistoryRefs + supported: boolean | undefined + onChange: (refs: GitHistoryRefs) => void +} +type RefRequests = { running: boolean; pending?: () => Promise } + +export function GitGraphFilter(props: GitGraphFilterProps) { + const requests = useRef({ running: false }) + const scope = useGitGraphScope(props.worktreeId, props.executionHostId) + const owner = JSON.stringify([props.worktreeId, scope?.route, scope?.worktree.path]) + return +} + +function GitGraphFilterForOwner({ + worktreeId, + executionHostId, + filter, + supported, + onChange, + owner, + requests +}: GitGraphFilterProps & { owner: string; requests: RefObject }) { + const [draft, setDraft] = useState() + const [query, setQuery] = useState('') + const [search, setSearch] = useState<{ + owner: string + query: string + refs?: GitHistoryItemRef[] + error?: string + }>() + const [probe, setProbe] = useState<{ owner: string | undefined; capable: boolean | null }>() + const pickerOpen = draft !== undefined + const draftRefs = draft ?? filter + const refs = + pickerOpen && search?.owner === owner && search.query === query ? (search.refs ?? []) : [] + const error = + pickerOpen && search?.owner === owner && search.query === query ? search.error : undefined + const chosen = useMemo(() => new Set(Array.isArray(draftRefs) ? draftRefs : []), [draftRefs]) + const capable = probe?.owner === owner ? probe?.capable : null + useEffect(() => { + let live = true + setProbe({ owner, capable: null }) + const scope = gitGraphScope(worktreeId, undefined, executionHostId) + if (scope) { + void ( + scope.target.kind === 'local' + ? Promise.resolve(true) + : runtimeEnvironmentSupportsCapability( + scope.target.environmentId, + GIT_HISTORY_REFS_CAPABILITY + ) + ) + .then((value) => { + if (live) { + setProbe({ owner, capable: value }) + } + }) + .catch(() => {}) + } + return () => { + live = false + } + }, [worktreeId, owner, executionHostId]) + useEffect(() => { + if (!pickerOpen || !capable) { + return + } + let live = true + const queue = requests.current + const drain = async () => { + if (queue.running) { + return + } + queue.running = true + try { + while (queue.pending) { + const next = queue.pending + queue.pending = undefined + await next() + } + } finally { + queue.running = false + } + } + const timeout = setTimeout(() => { + queue.pending = async () => { + const scope = gitGraphScope(worktreeId, undefined, executionHostId) + if (!live || !scope) { + return + } + try { + const rows = await getRuntimeGitRefs( + { + target: scope.target, + worktreeId, + worktreePath: scope.worktree.path, + connectionId: scope.connectionId ?? undefined + }, + query + ) + if (live) { + setSearch({ owner, query, refs: rows }) + } + } catch (cause) { + if (live) { + setSearch({ owner, query, error: String(cause) }) + } + } + } + void drain() + }, 150) + return () => { + live = false + queue.pending = undefined + clearTimeout(timeout) + } + }, [worktreeId, owner, query, pickerOpen, capable, executionHostId, requests]) + const apply = (refs = draftRefs) => { + const next = Array.isArray(refs) && !refs.length ? 'all' : refs + if (draft !== undefined && JSON.stringify(next) !== JSON.stringify(filter)) { + onChange(next) + } + setDraft(undefined) + setSearch(undefined) + } + return ( +
+ { + if (next) { + setDraft(filter) + } else { + apply() + } + }} + > + + + + { + event.preventDefault() + setDraft(undefined) + setSearch(undefined) + }} + > +
+
+ + +
+ setQuery(event.target.value)} + placeholder={translate('gitGraph.searchRefs', 'Search branches and tags')} + /> + {error &&

{error}

} +
+ {refs.map((ref) => ( + + ))} +
+
+
+
+ {capable === false || (supported === false && capable === true) ? ( + + {translate( + 'gitGraph.oldServer', + "This server's Orca version shows only the current branch" + )} + + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts b/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts new file mode 100644 index 00000000000..dad56592e0d --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-history-controller.ts @@ -0,0 +1,314 @@ +import { gitGraphOwnerKey, switchGitGraphWorkspace } from './git-graph-workspace-navigation' +import { createStore } from 'zustand/vanilla' +import { shallow } from 'zustand/shallow' +import { useAppStore } from '@/store' +import { gitGraphScope, gitGraphScopeInputs, GIT_GRAPH_REFRESH_EVENT } from '@/lib/git-graph-tab' +import { getRuntimeGitHistory } from '@/runtime/runtime-git-status-client' +import { translate } from '@/i18n/i18n' +import type { + GitHistoryRefs, + GitHistoryResult +} from '../../../../../shared/git/history/git-history-types' +import type { ExecutionHostId } from '../../../../../shared/execution-host' + +type HistoryView = { + result?: GitHistoryResult + error?: string + loading: boolean + selectedId?: string + revealId?: string +} +const controllers = new Map() +const filterFor = (worktreeId: string) => + useAppStore.getState().gitGraphFiltersByWorktree?.[worktreeId] ?? 'all' + +export function gitGraphHistoryForTab(worktreeId: string): GitGraphHistoryController { + const id = `${worktreeId}::git-graph` + let controller = controllers.get(id) + if (!controller) { + controller = new GitGraphHistoryController(worktreeId) + controllers.set(id, controller) + } + return controller +} + +export function disposeGitGraphHistory(tabId: string): void { + controllers.get(tabId)?.dispose() + controllers.delete(tabId) +} + +export class GitGraphHistoryController { + readonly view = createStore(() => ({ loading: false })) + scrollOffset = 0 + graphWidth = 132 + private scope: ReturnType + private filter: GitHistoryRefs + executionHostId: ExecutionHostId | undefined + private navigation = 0 + private generation = 0 + private dirty = true + private initial = true + private visible = 0 + private disposed = false + private pending?: Promise + private unsubscribe?: () => void + + constructor(readonly worktreeId: string) { + const state = useAppStore.getState() + const file = state.openFiles.find((file) => file.id === `${worktreeId}::git-graph`) + this.executionHostId = + file?.operationProvenance?.generation.route.executionHostId ?? + gitGraphScope(worktreeId, state)?.route.executionHostId ?? + undefined + this.scope = gitGraphScope(worktreeId, state, this.executionHostId) + this.filter = filterFor(worktreeId) + } + + attach(): () => void { + this.visible++ + if (!this.unsubscribe) { + this.unsubscribe = useAppStore.subscribe((state, previous) => { + if ( + state.openFiles === previous.openFiles && + state.gitGraphFiltersByWorktree?.[this.worktreeId] === + previous.gitGraphFiltersByWorktree?.[this.worktreeId] && + shallow(gitGraphScopeInputs(state), gitGraphScopeInputs(previous)) + ) { + return + } + this.synchronize() + }) + window.addEventListener(GIT_GRAPH_REFRESH_EVENT, this.onRefresh) + } + this.synchronize() + if (this.dirty) { + void this.retrieve(false, this.initial) + } + return () => { + this.visible-- + } + } + + private synchronize(): void { + const state = useAppStore.getState() + const file = state.openFiles.find((file) => file.id === `${this.worktreeId}::git-graph`) + this.executionHostId = + file?.operationProvenance?.generation.route.executionHostId ?? this.executionHostId + const scope = gitGraphScope(this.worktreeId, state, this.executionHostId) + const filter = filterFor(this.worktreeId) + if ( + gitGraphOwnerKey(scope) === gitGraphOwnerKey(this.scope) && + (filter === this.filter || JSON.stringify(filter) === JSON.stringify(this.filter)) + ) { + return + } + this.scope = scope + this.filter = filter + this.scrollOffset = 0 + this.view.setState({ loading: false }, true) + this.invalidate() + } + + private invalidate(): void { + this.generation++ + this.pending = undefined + this.dirty = true + if (this.visible) { + void this.retrieve(false, this.initial) + } + } + + private onRefresh = (event: Event): void => { + if (event instanceof CustomEvent && event.detail === this.worktreeId) { + this.invalidate() + } + } + + dispose(): void { + this.disposed = true + this.generation++ + this.unsubscribe?.() + window.removeEventListener(GIT_GRAPH_REFRESH_EVENT, this.onRefresh) + } + + switchToWorkspace = (refId: string): Promise => { + const generation = this.generation + return switchGitGraphWorkspace( + this.scope, + refId, + () => !this.disposed && generation === this.generation + ) + } + + select = (selectedId: string): void => { + this.navigation++ + this.view.setState({ selectedId, revealId: undefined }) + } + clearReveal = (): void => { + this.view.setState({ revealId: undefined }) + } + refresh = (): Promise => { + this.generation++ + this.pending = undefined + return this.retrieve(false, true) + } + loadMore = (): Promise => this.retrieve(true) + + reveal = async (id: string): Promise => { + const generation = this.generation + const navigation = ++this.navigation + this.clearReveal() + const current = () => + !this.disposed && generation === this.generation && navigation === this.navigation + while (this.pending && current()) { + await this.pending + } + if (!current()) { + return -1 + } + let result = this.view.getState().result + if (!result?.items.some((item) => item.id === id) && result?.hasMore) { + result = await this.retrieve(true, false, id, navigation) + } + if (!current()) { + return -1 + } + const index = result?.items.findIndex((item) => item.id === id) ?? -1 + if (index !== -1) { + this.view.setState({ selectedId: id, revealId: id }) + } + return index + } + + private retrieve( + append = false, + wakeWslDistro = false, + revealId?: string, + navigation?: number + ): Promise { + if (this.disposed) { + return Promise.resolve(undefined) + } + if (this.pending) { + return this.pending + } + const previous = this.view.getState().result + const context = this.scope + if (!context || (append && !previous?.hasMore)) { + return Promise.resolve(context ? previous : undefined) + } + const generation = this.generation + const targetDepth = previous?.items.length ?? 0 + const filter = this.filter + const current = () => !this.disposed && generation === this.generation + this.initial = false + this.dirty = false + this.view.setState({ loading: true, error: undefined }) + const run = async (): Promise => { + let accumulated = append ? previous : undefined + let items = [...(accumulated?.items ?? [])] + let ids = new Set(items.map((item) => item.id)) + let recovered = false + let error: string | undefined + try { + do { + const page = await getRuntimeGitHistory( + { + target: context.target, + worktreeId: this.worktreeId, + worktreePath: context.worktree.path, + connectionId: context.connectionId ?? undefined + }, + { + refs: filter, + limit: 50, + historyToken: accumulated?.historyToken, + skip: items.length, + wakeWslDistro + } + ) + if (!current()) { + return undefined + } + if (page.historyChanged) { + if (recovered) { + throw new Error( + translate( + 'gitGraph.historyChanged', + 'History changed again. Refresh the Git Graph to try again.' + ) + ) + } + recovered = true + accumulated = undefined + items = [] + ids = new Set() + continue + } + const count = items.length + for (const item of page.items) { + if (!ids.has(item.id)) { + ids.add(item.id) + items.push(item) + } + } + accumulated = { ...page, items } + if ( + (!revealId && + items.length >= targetDepth && + (count >= targetDepth || ids.has(this.view.getState().selectedId ?? ''))) || + (revealId && (navigation !== this.navigation || ids.has(revealId))) || + !page.hasMore || + items.length === count + ) { + break + } + } while (current()) + } catch (cause) { + error = + cause instanceof Error + ? cause.message + : translate('gitGraph.loadFailed', 'Failed to load Git history') + } + if (!current()) { + return undefined + } + const result = error !== undefined ? previous : (accumulated ?? previous) + const selection = this.view.getState().selectedId + const selectedId = + result?.items.find((item) => item.id === selection)?.id ?? result?.items[0]?.id + const selectionGone = selection !== undefined && selection !== selectedId + if (selectionGone) { + this.scrollOffset = 0 + } + this.view.setState({ + result, + error, + loading: false, + selectedId, + ...(selectionGone ? { revealId: selectedId } : {}) + }) + if ( + Array.isArray(filter) && + result?.resolvedRefs && + JSON.stringify(filter) !== JSON.stringify(result.resolvedRefs) + ) { + const refs = result.resolvedRefs + useAppStore.setState((state) => ({ + gitGraphFiltersByWorktree: { + ...state.gitGraphFiltersByWorktree, + [this.worktreeId]: refs.length ? refs : 'head' + } + })) + } + return result + } + const pending = run().finally(() => { + if (this.pending === pending) { + this.pending = undefined + } + }) + this.pending = pending + return pending + } +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx b/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx new file mode 100644 index 00000000000..e0cdd209c05 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-ref-menu.tsx @@ -0,0 +1,144 @@ +import { useMemo, useState } from 'react' +import { ArrowUpRight } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useAppStore } from '@/store' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import type { gitGraphScope } from '@/lib/git-graph-tab' +import { gitGraphWorkspaceCatalog } from './git-graph-workspace-navigation' +import { translate } from '@/i18n/i18n' +import type { GitHistoryItemRef } from '../../../../../shared/git/history/git-history-types' +import type { Worktree } from '../../../../../shared/worktree/types' +import { cn } from '@/lib/utils' + +export function useGitGraphWorkspaces(scope: ReturnType) { + const worktrees = useAppStore(selectWorktreesByRepo) + const repos = useAppStore((state) => state.repos) + return useMemo( + () => + new Map( + gitGraphWorkspaceCatalog(scope, { repos, worktreesByRepo: worktrees }).map((row) => [ + row.branch.startsWith('refs/heads/') ? row.branch : `refs/heads/${row.branch}`, + row + ]) + ), + [scope, worktrees, repos] + ) +} + +export function GitGraphRefs({ + references, + currentRefId, + workspaces, + onSwitch +}: { + references: GitHistoryItemRef[] + currentRefId?: string + workspaces: Map + onSwitch: (refId: string) => Promise +}) { + const [open, setOpen] = useState(false) + const [menuRef, setMenuRef] = useState() + const current = references.find((ref) => ref.id === currentRefId) + const inline = current ? [current] : [] + for (const ref of references) { + if (inline.length === 2) { + break + } + if (workspaces.has(ref.id) && ref !== current) { + inline.push(ref) + } + } + for (const ref of references) { + if (inline.length === 2) { + break + } + if (!inline.includes(ref)) { + inline.push(ref) + } + } + const badge = (ref: GitHistoryItemRef) => ( + + { + event.stopPropagation() + setMenuRef(ref) + }} + className={cn( + 'inline-flex min-w-0 max-w-full shrink items-center gap-1 rounded-full border px-1.5 text-2xs text-muted-foreground', + ref.id === currentRefId && 'bg-accent font-medium text-foreground' + )} + > + + {ref.name} + + {workspaces.has(ref.id) && ( + + + + + + {translate('gitGraph.switchWorkspace', 'Switch to workspace')} + + + )} + + + ) + return ( + { + if (!open) { + setMenuRef(undefined) + } + }} + > +
+ {inline.map(badge)} + {references.length > inline.length && ( + + + + + {open && ( + +
+ {references.filter((ref) => !inline.includes(ref)).map(badge)} +
+
+ )} +
+ )} +
+ {menuRef && ( + + void window.api.ui.writeClipboardText(menuRef.name)}> + {translate('gitGraph.copyBranch', 'Copy branch name')} + + {workspaces.has(menuRef.id) && ( + void onSwitch(menuRef.id)}> + {translate('gitGraph.switchWorkspace', 'Switch to workspace')} + + )} + + )} +
+ ) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx b/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx new file mode 100644 index 00000000000..58bd1199570 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-rendering.test.tsx @@ -0,0 +1,696 @@ +// @vitest-environment happy-dom +import { useAppStore } from '@/store' +import { gitGraphHistoryForTab, disposeGitGraphHistory } from './git-graph-history-controller' +import { attachClosedEditorTabCleanup } from '../model-lifetime/retention/closed-editor-tab-controller' +import { gitGraphScope } from '@/lib/git-graph-tab' +import { captureWorktreeOperationGenerationSnapshot } from '@/lib/worktree-operation-generation' +import type { Worktree } from '../../../../../shared/worktree/types' +import type { Repo } from '../../../../../shared/repo-types' +import type { OpenFile } from '@/store/slices/editor/types/open-file' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { GitHistoryPanel } from '@/components/right-sidebar/source-control/sync/git-history-panel' +import { GitGraphTab } from './git-graph-tab' +import { GitGraphFilter } from './git-graph-filter' +import { useGitGraphHistory, gitGraphFilterSupported } from './use-git-graph-history' +import type { GitHistoryResult } from '../../../../../shared/git/history/git-history-types' +import * as graph from '../../../../../shared/git/history/git-history-graph' + +const { getHistory, getRefs } = vi.hoisted(() => ({ getHistory: vi.fn(), getRefs: vi.fn() })) +vi.mock('@/runtime/runtime-git-status-client', async (original) => ({ + ...(await original()), + getRuntimeGitHistory: getHistory, + getRuntimeGitRefs: getRefs +})) +vi.mock('@/runtime/runtime-git-diff-client', async (original) => ({ + ...(await original()), + getRuntimeGitCommitCompare: vi.fn(async () => ({ summary: { status: 'ready' }, entries: [] })) +})) +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options: { count: number; getScrollElement: () => HTMLElement | null }) => ({ + getTotalSize: () => options.count * 28, + getVirtualItems: () => + Array.from({ length: options.count }, (_, index) => ({ index, start: index * 28 })), + measureElement: () => {}, + scrollToIndex: (index: number) => { + const element = options.getScrollElement() + if (element) { + element.scrollTop = index * 28 + } + } + }) +})) +const container = document.createElement('div') +document.body.appendChild(container) +let root = createRoot(container) +const initialState = useAppStore.getState() +const workspaceId = 'r::/repo' +const local: Worktree = { + id: workspaceId, + repoId: 'r', + path: '/repo', + branch: 'refs/heads/main', + hostId: 'local', + head: 'a', + isBare: false, + isMainWorktree: true, + displayName: 'repo', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 +} +const repo: Repo = { + id: 'r', + path: '/repo', + displayName: 'repo', + badgeColor: '', + addedAt: 0, + executionHostId: 'local' +} +beforeEach(() => { + useAppStore.setState({ + repos: [repo], + worktreesByRepo: { r: [local] }, + activeWorktreeId: workspaceId, + activeWorkspaceExecutionHostId: 'local', + gitGraphFiltersByWorktree: {} + }) +}) +afterEach(async () => { + await act(async () => root.unmount()) + root = createRoot(container) + disposeGitGraphHistory(`${workspaceId}::git-graph`) + useAppStore.setState(initialState, true) + vi.restoreAllMocks() +}) +const result: GitHistoryResult = { + items: [{ id: 'a', subject: 'subject', message: 'subject', parentIds: [] }], + hasMore: false, + hasIncomingChanges: false, + hasOutgoingChanges: false, + limit: 50 +} + +it('keeps view models on expand and resize, rebuilding when history changes', async () => { + const build = vi.spyOn(graph, 'buildGitHistoryViewModels') + const render = (history: GitHistoryResult) => ( + + {}} + onRefresh={() => {}} + onLoadCommitFiles={async () => []} + onOpenCommitFile={() => {}} + /> + + ) + await act(async () => root.render(render(result))) + expect(build).toHaveBeenCalledTimes(1) + await act(async () => + container.querySelector('[data-testid="git-history-row"]')!.click() + ) + await act(async () => + container + .querySelector('[role="separator"]')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + ) + expect(build).toHaveBeenCalledTimes(1) + await act(async () => + root.render( + render({ + ...result, + items: [...result.items, { id: 'b', subject: 'next', message: 'next', parentIds: [] }] + }) + ) + ) + expect(build).toHaveBeenCalledTimes(2) + expect( + container.querySelector('[data-testid="git-history-row"]')?.getAttribute('aria-expanded') + ).toBe('true') +}) + +it('disables the branch filter when an older host omits the refs echo', async () => { + await act(async () => + root.render( + + {}} + /> + + ) + ) + expect(container.querySelector('button')?.disabled).toBe(true) + await act(async () => + root.render( + + {}} + /> + + ) + ) + expect(container.querySelector('button')?.disabled).toBe(false) +}) + +it('recovers silently after each later token expiry and ignores refreshes for other worktrees', async () => { + let history: ReturnType + function History() { + history = useGitGraphHistory(workspaceId) + return {history.error} + } + const page = { ...result, refs: 'all' as const, hasMore: true, historyToken: 'first' } + getHistory.mockReset().mockResolvedValueOnce(page) + await act(async () => root.render()) + const changed = { ...page, items: [], historyChanged: true } + getHistory + .mockResolvedValueOnce(changed) + .mockResolvedValueOnce({ ...page, historyToken: 'second' }) + await act(async () => { + await history!.loadMore() + }) + expect(history!.result?.historyToken).toBe('second') + getHistory + .mockResolvedValueOnce(changed) + .mockResolvedValueOnce({ ...page, historyToken: 'third' }) + await act(async () => { + await history!.loadMore() + }) + expect(history!.result?.historyToken).toBe('third') + expect(history!.error).toBeUndefined() + getHistory.mockResolvedValue({ ...page, historyToken: 'refreshed' }) + await act(async () => + window.dispatchEvent(new CustomEvent('orca:git-graph-refresh', { detail: 'other' })) + ) + expect(history!.result?.historyToken).toBe('third') + await act(async () => + window.dispatchEvent(new CustomEvent('orca:git-graph-refresh', { detail: workspaceId })) + ) + expect(history!.result?.historyToken).toBe('refreshed') + expect(getHistory.mock.calls.at(-1)?.[1].wakeWslDistro).toBe(false) +}) + +it('keeps an older selection, loaded depth and scroll through refresh', async () => { + const items = Array.from({ length: 150 }, (_, index) => ({ + id: `commit-${index}`, + subject: `Commit ${index}`, + message: `Commit ${index}`, + parentIds: index < 149 ? [`commit-${index + 1}`] : [] + })) + const page = (skip: number): GitHistoryResult => ({ + ...result, + refs: 'all', + historyToken: 'before-refresh', + items: items.slice(skip, skip + 50), + hasMore: skip < 100 + }) + getHistory.mockReset().mockResolvedValueOnce(page(0)) + await act(async () => + root.render( + + + + ) + ) + const controller = gitGraphHistoryForTab(workspaceId) + getHistory.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(100)) + await act(async () => { + await controller.loadMore() + await controller.loadMore() + }) + const scroll = container.querySelector('div.overflow-y-auto')! + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Commit 110')! + .click() + scroll.scrollTop = 2400 + scroll.dispatchEvent(new Event('scroll')) + }) + const refreshed = [ + { id: 'new-tip', subject: 'New commit', message: 'New commit', parentIds: ['commit-0'] }, + ...items + ] + const refreshedPage = (skip: number): GitHistoryResult => ({ + ...result, + refs: 'all', + historyToken: 'after-refresh', + items: refreshed.slice(skip, skip + 50), + hasMore: true + }) + let finish!: (page: GitHistoryResult) => void + getHistory + .mockResolvedValueOnce(refreshedPage(0)) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + .mockResolvedValueOnce(refreshedPage(100)) + await act(async () => { + container.querySelector('button[aria-label="Refresh commits"]')!.click() + }) + expect(container.querySelector('[data-current="true"]')?.textContent).toContain('Commit 110') + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + await act(async () => { + finish(refreshedPage(50)) + await controller.loadMore() + }) + expect(container.querySelector('[data-current="true"]')?.textContent).toContain('Commit 110') + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + expect(scroll.scrollTop).toBe(2400) + + getHistory + .mockResolvedValueOnce(refreshedPage(0)) + .mockResolvedValueOnce(refreshedPage(50)) + .mockResolvedValueOnce(refreshedPage(100)) + await act(async () => { + window.dispatchEvent(new CustomEvent('orca:git-graph-refresh', { detail: workspaceId })) + await controller.loadMore() + }) + expect(container.querySelector('[data-current="true"]')?.textContent).toContain('Commit 110') + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + expect(scroll.scrollTop).toBe(2400) + + getHistory + .mockResolvedValueOnce({ ...result, items: [], historyChanged: true }) + .mockResolvedValueOnce(refreshedPage(0)) + .mockResolvedValueOnce(refreshedPage(50)) + .mockResolvedValueOnce(refreshedPage(100)) + await act(async () => { + await controller.loadMore() + }) + expect(container.querySelector('[data-current="true"]')?.textContent).toContain('Commit 110') + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + expect(scroll.scrollTop).toBe(2400) +}) + +async function loadOlderHistory() { + const items = Array.from({ length: 300 }, (_, index) => ({ + id: `commit-${index}`, + subject: `Commit ${index}`, + message: `Commit ${index}`, + parentIds: [] + })) + const page = (skip: number): GitHistoryResult => ({ + ...result, + refs: 'all', + historyToken: 'pinned', + items: items.slice(skip, skip + 50), + hasMore: skip < 250 + }) + getHistory.mockReset().mockImplementation(async (_owner, options) => page(options.skip)) + await act(async () => + root.render( + + + + ) + ) + const controller = gitGraphHistoryForTab(workspaceId) + await act(async () => { + await controller.loadMore() + await controller.loadMore() + }) + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Commit 110')! + .click() + }) + getHistory.mockClear() + return { controller, items, page } +} + +it('keeps a selection pushed past the loaded depth with exactly one extra page', async () => { + const { controller, items } = await loadOlderHistory() + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Commit 149')! + .click() + }) + const refreshed = [ + { id: 'new-tip', subject: 'New commit', message: 'New commit', parentIds: [] }, + ...items + ] + getHistory.mockImplementation(async (_owner, options) => ({ + ...result, + refs: 'all', + historyToken: 'refreshed', + items: refreshed.slice(options.skip, options.skip + 50), + hasMore: true + })) + await act(async () => { + await controller.refresh() + }) + expect(getHistory).toHaveBeenCalledTimes(4) + expect(container.querySelectorAll('[data-current]')).toHaveLength(200) + expect(container.querySelector('[data-current="true"] button[title="Commit 149"]')).not.toBeNull() + expect(container.querySelector('aside h2')?.textContent).toBe('Commit 149') +}) + +it('bounds refresh to one extra page when the selected commit has vanished', async () => { + const { controller, items } = await loadOlderHistory() + const remaining = items.filter((item) => item.id !== 'commit-110') + getHistory.mockImplementation(async (_owner, options) => ({ + ...result, + refs: 'all', + items: remaining.slice(options.skip, options.skip + 50), + hasMore: options.skip < 250 + })) + const scroll = container.querySelector('div.overflow-y-auto')! + await act(async () => { + scroll.scrollTop = 2400 + controller.scrollOffset = 2400 + await controller.refresh() + }) + expect(getHistory).toHaveBeenCalledTimes(4) + expect(container.querySelectorAll('[data-current]')).toHaveLength(200) + expect(container.querySelector('[data-current="true"] button[title="Commit 0"]')).not.toBeNull() + expect(container.querySelector('aside h2')?.textContent).toBe('Commit 0') + expect(scroll.scrollTop).toBe(0) +}) + +it('keeps a newer selection and its details during refresh and after a partial failure', async () => { + const { controller, page } = await loadOlderHistory() + let finish!: (page: GitHistoryResult) => void + getHistory.mockResolvedValueOnce(page(0)).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + let refreshing!: Promise + await act(async () => { + refreshing = controller.refresh() + }) + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'Commit 120')! + .click() + finish(page(50)) + await refreshing + }) + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + expect(container.querySelector('[data-current="true"] button[title="Commit 120"]')).not.toBeNull() + expect(container.querySelector('aside h2')?.textContent).toBe('Commit 120') + + getHistory + .mockResolvedValueOnce(page(0)) + .mockRejectedValueOnce(Object.assign(new Error('Transport failure'), { message: '' })) + await act(async () => { + await controller.refresh() + }) + expect(container.querySelectorAll('[data-current]')).toHaveLength(150) + expect(container.querySelector('[data-current="true"] button[title="Commit 120"]')).not.toBeNull() + expect(container.querySelector('aside h2')?.textContent).toBe('Commit 120') +}) + +it.each(['local', 'ssh:A'] as const)( + 'retains %s twin history across workspace switches, then follows its tab owner move', + async (hostId) => { + useAppStore.setState({ + repos: [ + ...useAppStore.getState().repos, + { ...repo, executionHostId: 'ssh:A', connectionId: 'A' } + ], + worktreesByRepo: { r: [local, { ...local, hostId: 'ssh:A' }] }, + activeWorkspaceExecutionHostId: hostId + }) + const cleanup = attachClosedEditorTabCleanup(useAppStore) + const original = useAppStore.getState().openFiles + const file: OpenFile = { + id: `${workspaceId}::git-graph`, + worktreeId: workspaceId, + mode: 'git-graph', + relativePath: 'Git Graph', + language: 'plaintext', + isDirty: false, + filePath: `${workspaceId}::git-graph`, + operationProvenance: { + ownershipProjection: 'explicit', + generation: captureWorktreeOperationGenerationSnapshot({ + executionHostId: hostId, + runtimeEnvironmentId: null + }) + } + } + useAppStore.setState({ openFiles: [...original, file] }) + const render = () => ( + + + + ) + const page = { + ...result, + items: [...result.items, { id: 'b', subject: 'second', message: 'second', parentIds: [] }], + refs: 'all' as const + } + getHistory.mockReset().mockResolvedValueOnce(page) + try { + await act(async () => root.render(render())) + expect(getHistory.mock.calls[0]?.[0].connectionId).toBe(hostId === 'ssh:A' ? 'A' : undefined) + await act(async () => { + const commit = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'second' + )! + commit.click() + const scroll = container.querySelector('div.overflow-y-auto')! + scroll.scrollTop = 144 + scroll.dispatchEvent(new Event('scroll')) + }) + await act(async () => root.render(null)) + await act(async () => + useAppStore.setState({ activeWorktreeId: 'other', activeWorkspaceExecutionHostId: 'local' }) + ) + expect(gitGraphScope(workspaceId)).toBeNull() + expect(gitGraphHistoryForTab(workspaceId).view.getState().selectedId).toBe('b') + await act(async () => + useAppStore.setState({ + activeWorktreeId: workspaceId, + activeWorkspaceExecutionHostId: hostId + }) + ) + await act(async () => root.render(render())) + expect(container.querySelector('[data-current="true"]')?.textContent).toContain('second') + expect(container.querySelector('div.overflow-y-auto')?.scrollTop).toBe(144) + expect(getHistory).toHaveBeenCalledTimes(1) + await act(async () => root.render(null)) + useAppStore.setState({ + worktreesByRepo: { r: [{ ...local, hostId: hostId === 'local' ? 'ssh:A' : 'local' }] } + }) + expect(gitGraphHistoryForTab(workspaceId).view.getState().result).toBeUndefined() + expect(gitGraphHistoryForTab(workspaceId).scrollOffset).toBe(0) + await act(async () => root.render(render())) + const movedHost = hostId === 'local' ? 'ssh:A' : 'local' + getHistory.mockResolvedValueOnce({ ...result, refs: 'all' }) + await act(async () => + useAppStore.setState({ + openFiles: [ + ...original, + { + ...file, + operationProvenance: { + ownershipProjection: 'explicit', + generation: captureWorktreeOperationGenerationSnapshot({ + executionHostId: movedHost, + runtimeEnvironmentId: null + }) + } + } + ] + }) + ) + expect(getHistory.mock.calls.at(-1)?.[0].connectionId).toBe( + movedHost === 'ssh:A' ? 'A' : undefined + ) + expect(gitGraphHistoryForTab(workspaceId).view.getState().selectedId).toBe('a') + await act(async () => root.render(null)) + useAppStore.setState({ openFiles: original }) + expect(gitGraphHistoryForTab(workspaceId).view.getState().result).toBeUndefined() + } finally { + cleanup() + useAppStore.setState({ openFiles: original }) + } + } +) + +it('waits for an in-flight page before retrieving a distant parent in one operation', async () => { + let history: ReturnType + function History() { + history = useGitGraphHistory(workspaceId) + return null + } + const ids = ['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'parent'] + getHistory.mockReset().mockResolvedValueOnce({ ...result, hasMore: true, historyToken: 'pinned' }) + await act(async () => root.render()) + let finish!: (page: GitHistoryResult) => void + getHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const loading = history!.loadMore() + for (const id of ids.slice(1)) { + getHistory.mockResolvedValueOnce({ + ...result, + items: [{ id, subject: id, message: id, parentIds: [] }], + hasMore: true, + historyToken: 'pinned' + }) + } + await act(async () => { + const revealing = history!.reveal('parent') + finish({ + ...result, + items: [{ id: 'b', subject: 'b', message: 'b', parentIds: [] }], + hasMore: true, + historyToken: 'pinned' + }) + await loading + await revealing + }) + expect(history!.selectedId).toBe('parent') + expect(history!.result?.items.map((item) => item.id)).toEqual([ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'parent' + ]) +}) + +it('keeps retrieved pages without revealing over a newer selection or parent navigation', async () => { + getHistory.mockReset().mockResolvedValueOnce({ ...result, hasMore: true }) + const controller = gitGraphHistoryForTab(workspaceId) + const detach = controller.attach() + await controller.loadMore() + let finish!: (page: GitHistoryResult) => void + getHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const revealing = controller.reveal('parent') + await Promise.resolve() + controller.select('a') + finish({ + ...result, + items: [{ id: 'parent', subject: 'parent', message: 'parent', parentIds: [] }], + hasMore: true + }) + expect(await revealing).toBe(-1) + expect(controller.view.getState().result?.items.map((item) => item.id)).toEqual(['a', 'parent']) + expect(controller.view.getState().selectedId).toBe('a') + expect(controller.view.getState().revealId).toBeUndefined() + + getHistory.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const older = controller.reveal('older') + await Promise.resolve() + const newer = controller.reveal('a') + finish({ ...result, items: [{ id: 'older', subject: 'older', message: 'older', parentIds: [] }] }) + expect(await older).toBe(-1) + expect(await newer).toBe(0) + expect(controller.view.getState().selectedId).toBe('a') + expect(controller.view.getState().revealId).toBe('a') + detach() +}) + +it('applies checkbox choices once on picker close, with no choices returning to All', async () => { + const onChange = vi.fn() + getRefs.mockReset().mockResolvedValue([ + { id: 'refs/heads/b', name: 'b', type: 'head' }, + { id: 'refs/heads/a', name: 'a', type: 'head' } + ]) + const render = (filter: 'all' | string[]) => + root.render( + + + + ) + await act(async () => render('all')) + await act(async () => container.querySelector('button')!.click()) + await vi.waitFor(async () => { + await act(async () => {}) + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + }) + const checkboxes = Array.from(document.querySelectorAll('[role="checkbox"]')) + expect(checkboxes).toHaveLength(2) + await act(async () => checkboxes[0]!.click()) + await act(async () => checkboxes[1]!.click()) + expect(onChange).not.toHaveBeenCalled() + await act(async () => container.querySelector('button')!.click()) + expect(onChange.mock.calls).toEqual([[['refs/heads/a', 'refs/heads/b']]]) + + await act(async () => render(['refs/heads/a', 'refs/heads/b'])) + await act(async () => container.querySelector('button')!.click()) + await vi.waitFor(async () => { + await act(async () => {}) + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + }) + for (const checkbox of document.querySelectorAll('[role="checkbox"]')) { + await act(async () => checkbox.click()) + } + expect(onChange).toHaveBeenCalledTimes(1) + await act(async () => container.querySelector('button')!.click()) + expect(onChange.mock.calls.at(-1)).toEqual(['all']) + + const openPicker = async () => { + await act(async () => container.querySelector('button')!.click()) + await vi.waitFor(async () => { + await act(async () => {}) + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + }) + } + await act(async () => render('all')) + await openPicker() + await act(async () => document.querySelector('[role="checkbox"]')!.click()) + await act(async () => + document + .querySelector('[role="dialog"]')! + .dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }) + ) + ) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(onChange).toHaveBeenCalledTimes(2) + + await openPicker() + expect(document.querySelector('[role="checkbox"]')?.getAttribute('aria-checked')).toBe('false') + await act(async () => document.querySelector('[role="checkbox"]')!.click()) + await act(async () => useAppStore.setState({ worktreesByRepo: {} })) + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => useAppStore.setState({ worktreesByRepo: { r: [local] } })) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(onChange).toHaveBeenCalledTimes(2) + await openPicker() + expect(document.querySelector('[role="checkbox"]')?.getAttribute('aria-checked')).toBe('false') +}) diff --git a/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx b/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx new file mode 100644 index 00000000000..1a22a15a789 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-tab.tsx @@ -0,0 +1,276 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useSidebarResize } from '@/hooks/useSidebarResize' +import { useVirtualizer } from '@tanstack/react-virtual' +import { RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' +import { GitHistoryCommitContextMenu } from '@/components/right-sidebar/source-control/sync/git-history-commit-context-menu' +import { GitHistoryGraphSvg } from '@/components/right-sidebar/source-control/sync/git-history-graph-svg' +import { useGitHistoryCommitActions } from '@/components/right-sidebar/source-control/sync/use-git-history-commit-actions' +import { formatGitHistoryTimestamp } from '@/components/right-sidebar/source-control/sync/git-history-format' +import { + buildDefaultGitHistoryColorMap, + buildGitHistoryViewModels +} from '../../../../../shared/git/history/git-history-graph' +import { useGitGraphHistory, gitGraphFilterSupported } from './use-git-graph-history' +import { GitGraphFilter } from './git-graph-filter' +import { GitGraphDetails } from './git-graph-details' +import { GitGraphRefs, useGitGraphWorkspaces } from './git-graph-ref-menu' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +const sameGroup = () => undefined + +export function GitGraphTab({ worktreeId }: { worktreeId: string }) { + const history = useGitGraphHistory(worktreeId) + const { result, scope, selectedId, revealId, controller } = history + const workspaces = useGitGraphWorkspaces(scope) + const rows = useMemo( + () => + result + ? buildGitHistoryViewModels( + result.items, + buildDefaultGitHistoryColorMap(result), + result.currentRef + ) + : [], + [result] + ) + const maxGraphWidth = useMemo( + () => + rows.reduce( + (width, row) => + Math.max( + width, + (Math.max(row.inputSwimlanes.length, row.outputSwimlanes.length) + 1) * 11 + ), + 132 + ), + [rows] + ) + const [graphWidth, setGraphWidth] = useState(controller.graphWidth) + const [draftGraphWidth, setDraftGraphWidth] = useState(graphWidth) + const commitGraphWidth = useCallback( + (width: number) => { + controller.graphWidth = width + setGraphWidth(width) + }, + [controller] + ) + const { containerRef: graphHeader, onResizeStart } = useSidebarResize({ + isOpen: true, + width: graphWidth, + minWidth: 44, + maxWidth: maxGraphWidth, + deltaSign: 1, + setWidth: commitGraphWidth, + onDraftWidthChange: setDraftGraphWidth + }) + const columns = `160px ${draftGraphWidth}px minmax(200px,1fr) 120px 100px` + const scroll = useRef(null) + const virtualizer = useVirtualizer({ + count: rows.length, + initialOffset: () => history.controller.scrollOffset, + getScrollElement: () => scroll.current, + estimateSize: () => 24, + overscan: 12, + getItemKey: (index) => rows[index]!.historyItem.id + }) + const selected = result?.items.find((item) => item.id === selectedId) + useLayoutEffect(() => { + if (scroll.current) { + scroll.current.scrollTop = controller.scrollOffset + } + }, [controller]) + useLayoutEffect(() => { + if (!revealId) { + return + } + const index = rows.findIndex((row) => row.historyItem.id === revealId) + if (index !== -1) { + virtualizer.scrollToIndex(index) + controller.clearReveal() + } + }, [rows, revealId, virtualizer, controller]) + const actions = useGitHistoryCommitActions({ + activeWorktreeId: worktreeId, + worktreePath: scope?.worktree.path ?? null, + activeRepoTarget: scope?.target ?? null, + ownerConnectionId: scope?.connectionId, + resolveSplitTargetGroupId: sameGroup + }) + return ( +
+
+ + + + + + {translate('gitGraph.refresh', 'Refresh commits')} + +
+ {history.error && ( +
{history.error}
+ )} +
+
+
+ {translate('gitGraph.branchTag', 'Branch / Tag')} +
+ {translate('gitGraph.graph', 'Graph')} +
commitGraphWidth(132)} + onKeyDown={(event) => { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + commitGraphWidth( + Math.max( + 44, + Math.min(maxGraphWidth, graphWidth + (event.key === 'ArrowLeft' ? -11 : 11)) + ) + ) + } else if (event.key === 'Home') { + event.preventDefault() + commitGraphWidth(132) + } else if (event.key === 'End') { + event.preventDefault() + commitGraphWidth(maxGraphWidth) + } + }} + /> +
+ {translate('gitGraph.message', 'Message')} + {translate('gitGraph.author', 'Author')} + {translate('gitGraph.date', 'Date')} +
+
{ + const element = scroll.current + if (element) { + controller.scrollOffset = element.scrollTop + } + if ( + element && + element.scrollHeight - element.scrollTop - element.clientHeight < 200 && + !history.loading + ) { + void history.loadMore() + } + }} + > +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const viewModel = rows[virtualRow.index]! + const item = viewModel.historyItem + return ( + + +
controller.select(item.id)} + > +
+ +
+
+ +
+ + {item.author} + + {formatGitHistoryTimestamp(item.timestamp)} + +
+
+ +
+ ) + })} +
+ {result?.hasMore && ( + + )} + {result && !result.items.length && ( +
+ {translate('gitGraph.noCommits', 'No commits yet')} +
+ )} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/editor/git-graph/git-graph-workspace-navigation.ts b/src/renderer/src/components/editor/git-graph/git-graph-workspace-navigation.ts new file mode 100644 index 00000000000..a9833d08a15 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/git-graph-workspace-navigation.ts @@ -0,0 +1,89 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { normalizeRuntimePathForComparison } from '../../../../../shared/cross-platform-path' +import { getWorktreeExecutionHostId } from '../../../../../shared/execution-host' +import { findIndexedRepoOwnerForHost } from '@/lib/workspace/ownership/repo-owner-index' +import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { useAppStore } from '@/store' +import { gitGraphScope } from '@/lib/git-graph-tab' +import { getRuntimeGitRefs } from '@/runtime/runtime-git-status-client' + +export const gitGraphOwnerKey = (scope: ReturnType) => + JSON.stringify([scope?.route, scope?.worktree.path]) +export function gitGraphWorkspaceCatalog( + scope: ReturnType, + state: Pick< + ReturnType, + 'repos' | 'worktreesByRepo' + > = useAppStore.getState() +) { + if (!scope?.route.executionHostId) { + return [] + } + const repo = findIndexedRepoOwnerForHost( + state.repos, + scope.worktree.repoId, + scope.route.executionHostId + ) + return (selectWorktreesByRepo(state)[scope.worktree.repoId] ?? []).filter( + (row) => + row.id !== scope.worktree.id && + getWorktreeExecutionHostId(row, repo ?? undefined) === scope.route.executionHostId + ) +} + +export async function switchGitGraphWorkspace( + scope: ReturnType, + refId: string, + isLive: () => boolean +): Promise { + const hostId = scope?.route.executionHostId + if (!scope || !hostId || !isLive()) { + return + } + const worktreeId = scope.worktree.id + const current = () => + isLive() && + gitGraphOwnerKey(scope) === + gitGraphOwnerKey(gitGraphScope(worktreeId, useAppStore.getState(), hostId)) + try { + const refs = await getRuntimeGitRefs( + { + target: scope.target, + worktreeId: worktreeId, + worktreePath: scope.worktree.path, + connectionId: scope.connectionId ?? undefined + }, + refId + ) + if (!current()) { + return + } + const path = refs.find((ref) => ref.id === refId)?.worktreePath + const workspace = + path && + gitGraphWorkspaceCatalog(scope).find( + (row) => + normalizeRuntimePathForComparison(row.path) === normalizeRuntimePathForComparison(path) + ) + if (workspace) { + useAppStore.getState().setActiveWorktree(workspace.id, hostId) + } else { + toast.message( + translate( + 'gitGraph.workspaceClosed', + "This branch isn't open in another workspace anymore." + ) + ) + } + } catch { + if (current()) { + toast.message( + translate( + 'gitGraph.workspaceCheckFailed', + "Couldn't check this workspace. Try again when connected." + ) + ) + } + } +} diff --git a/src/renderer/src/components/editor/git-graph/use-git-graph-history.ts b/src/renderer/src/components/editor/git-graph/use-git-graph-history.ts new file mode 100644 index 00000000000..236877f4ba7 --- /dev/null +++ b/src/renderer/src/components/editor/git-graph/use-git-graph-history.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react' +import { useStore } from 'zustand' +import { useAppStore } from '@/store' +import { useGitGraphScope } from '@/lib/git-graph-tab' +import { gitGraphHistoryForTab } from './git-graph-history-controller' +import type { + GitHistoryRefs, + GitHistoryResult +} from '../../../../../shared/git/history/git-history-types' + +export function gitGraphFilterSupported(result?: GitHistoryResult): boolean | undefined { + return result ? result.refs !== undefined : undefined +} + +export function useGitGraphHistory(worktreeId: string) { + const filter = useAppStore((state) => state.gitGraphFiltersByWorktree?.[worktreeId] ?? 'all') + const controller = gitGraphHistoryForTab(worktreeId) + const scope = useGitGraphScope(worktreeId, controller.executionHostId) + const view = useStore(controller.view) + useEffect(() => controller.attach(), [controller]) + const changeFilter = (refs: GitHistoryRefs) => + useAppStore.setState((state) => ({ + gitGraphFiltersByWorktree: { + ...state.gitGraphFiltersByWorktree, + [worktreeId]: Array.isArray(refs) ? [...new Set(refs)].sort() : refs + } + })) + return { + ...view, + filter, + scope, + controller, + changeFilter, + refresh: controller.refresh, + loadMore: controller.loadMore, + reveal: controller.reveal + } +} diff --git a/src/renderer/src/components/editor/model-lifetime/retention/closed-editor-tab-controller.ts b/src/renderer/src/components/editor/model-lifetime/retention/closed-editor-tab-controller.ts index 934b5e25bb5..234a5635baa 100644 --- a/src/renderer/src/components/editor/model-lifetime/retention/closed-editor-tab-controller.ts +++ b/src/renderer/src/components/editor/model-lifetime/retention/closed-editor-tab-controller.ts @@ -1,3 +1,4 @@ +import { disposeGitGraphHistory } from '../../git-graph/git-graph-history-controller' import type { StoreApi } from 'zustand' import type { editor } from 'monaco-editor' import type { OpenFile } from '@/store/slices/editor/types/open-file' @@ -260,6 +261,9 @@ export function attachClosedEditorTabCleanup( filePath: file.filePath, modelOwnerKey: priorOwnerOf(file) } + if (file.mode === 'git-graph') { + disposeGitGraphHistory(file.id) + } pendingFiles.set(ownerKey(descriptor), descriptor) removed = true if (file.mode === 'edit' && registry) { diff --git a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts index b59af4cc74d..864b30041c4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts +++ b/src/renderer/src/components/right-sidebar/source-control/commit/use-commit-action.ts @@ -1,3 +1,4 @@ +import { GIT_GRAPH_REFRESH_EVENT } from '@/lib/git-graph-tab' import { useCallback } from 'react' import { toast } from 'sonner' import { requireGitOwnerTarget } from '../sync/worktree-git-owner-target' @@ -112,6 +113,9 @@ export function useSourceControlCommitAction({ } if (!options?.target) { void refreshBranchCompareRef.current() + window.dispatchEvent( + new CustomEvent(GIT_GRAPH_REFRESH_EVENT, { detail: target.worktreeId }) + ) void refreshGitHistoryRef.current() } return true @@ -184,6 +188,7 @@ export function useSourceControlCommitAction({ ) } void refreshBranchCompareRef.current() + window.dispatchEvent(new CustomEvent(GIT_GRAPH_REFRESH_EVENT, { detail: activeWorktreeId })) void refreshGitHistoryRef.current() }, [ activeRepoTarget, diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx index 64f56ad9ed1..dd080ed40c4 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/panel-content.tsx @@ -226,6 +226,7 @@ export function SourceControlPanelContent(props: SourceControlPanelReadyProps) { // Dock history as reference context while the rest of the pane scrolls.
toggleSection('history')} diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/worktree-scope.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-scope.tsx index 52922f7e3ca..1b7580f24b8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/worktree-scope.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/worktree-scope.tsx @@ -121,11 +121,15 @@ export function sourceControlScopeRuntimeEnvironmentId( ) } -/** A folder section's repo connection for its own worktree; otherwise the selected copy's. */ +/** A folder section's repo connection for its own worktree; otherwise the selected copy's. An explicit owner connection wins. */ export function sourceControlConnectionId( scope: SourceControlScopeRouting | null, - worktreeId: string | null + worktreeId: string | null, + ownerConnectionId?: string | null ): string | undefined { + if (ownerConnectionId !== undefined) { + return ownerConnectionId ?? undefined + } return ( (scope?.worktreeId === worktreeId ? scope.connectionId : getConnectionId(worktreeId)) ?? undefined diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-graph-open-button.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-graph-open-button.tsx new file mode 100644 index 00000000000..e88c53af85b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-graph-open-button.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react' +import { GitGraph } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useSourceControlState } from '../panel/worktree-scope' +import { + gitGraphScope, + gitGraphSupport, + gitGraphUpdateMessage, + openGitGraph, + probeGitGraphSupport +} from '@/lib/git-graph-tab' +import { translate } from '@/i18n/i18n' + +export function GitGraphOpenButton({ worktreeId }: { worktreeId: string }) { + // Hidden section bodies stop these subscriptions; a primitive key keeps unrelated writes from rendering. + const owner = useSourceControlState((state) => + JSON.stringify([worktreeId, gitGraphScope(worktreeId, state)?.route]) + ) + const status = useSourceControlState((state) => { + const environmentId = gitGraphScope(worktreeId, state)?.route.runtimeEnvironmentId + return environmentId ? state.runtimeStatusByEnvironmentId.get(environmentId) : undefined + }) + const [probe, setProbe] = useState<{ owner: string; support: boolean | null }>() + const support = probe?.owner === owner ? probe.support : null + useEffect(() => { + let current = true + setProbe({ owner, support: gitGraphSupport(worktreeId) }) + void probeGitGraphSupport(worktreeId) + .then((value) => { + if (current) { + setProbe({ owner, support: value }) + } + }) + .catch(() => {}) + return () => { + current = false + } + }, [worktreeId, owner, status]) + return ( + + + + + + + {support !== null && ( + + {support ? translate('gitGraph.title', 'Git Graph') : gitGraphUpdateMessage(worktreeId)} + + )} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx index 5da2ab6de87..9ba5779b63c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-commit-files.tsx @@ -1,6 +1,6 @@ import { createElement } from 'react' import type React from 'react' -import { ArrowUpRight, RefreshCw } from 'lucide-react' +import { ArrowUpRight } from 'lucide-react' import { STATUS_COLORS } from '../../../../lib/git-path-decoration' import { STATUS_LABELS } from '../../../../../../shared/git/status/git-status-labels' import { @@ -46,9 +46,24 @@ function CommitFileRow({ className: 'size-3.5 shrink-0', style: { color: STATUS_COLORS[status] } })} - - {fileName} - {dirPath && {dirPath}} + + + {fileName} + + {dirPath && ( + + {dirPath} + + )} + + + {entry.added !== undefined ? `+${entry.added}` : ''} + + + {entry.removed !== undefined ? `−${entry.removed}` : ''} void }): React.JSX.Element { if (state.status === 'loading') { - return ( -
- - - {translate( - 'auto.components.right.sidebar.GitHistoryCommitFiles.a1b2c3d4e5', - 'Loading files…' - )} - -
- ) + return
} if (state.status === 'error') { diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx index cd2071398d4..18ec98bea4f 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx @@ -1,3 +1,5 @@ +import { useVirtualizer } from '@tanstack/react-virtual' +import { GitGraphOpenButton } from './git-graph-open-button' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronDown, CircleHelp, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -45,6 +47,7 @@ function clampGitHistoryPanelHeight(height: number): number { export function GitHistoryPanel({ state, + worktreeId, collapsed, onToggle, onRefresh, @@ -53,6 +56,7 @@ export function GitHistoryPanel({ onOpenCommitFile, onCommitAction }: { + worktreeId?: string state: GitHistoryPanelState collapsed: boolean onToggle: () => void @@ -90,20 +94,28 @@ export function GitHistoryPanel({ const [expanded, setExpanded] = useState>(() => new Set()) const [filesByCommit, setFilesByCommit] = useState>({}) - // Avoid duplicate/in-flight expansion fetches; clear errors to permit retry. - const loadedCommitsRef = useRef<{ - result: GitHistoryResult | undefined - ids: Set - }>(undefined!) - loadedCommitsRef.current ??= { result, ids: new Set() } - - // New history can replace rows; discard expansions and cached files to avoid stale associations. - const [historyResult, setHistoryResult] = useState(result) - if (historyResult !== result) { - setHistoryResult(result) - setExpanded(new Set()) - setFilesByCommit({}) + const loadedCommitsRef = useRef(new Set()) + const [previousResult, setPreviousResult] = useState(result) + if (result !== previousResult) { + setPreviousResult(result) + const ids = new Set(result?.items.map((item) => item.id)) + setExpanded(new Set([...expanded].filter((id) => ids.has(id)))) + setFilesByCommit( + Object.fromEntries(Object.entries(filesByCommit).filter(([id]) => ids.has(id))) + ) } + useEffect(() => { + const ids = new Set(result?.items.map((item) => item.id)) + loadedCommitsRef.current = new Set([...loadedCommitsRef.current].filter((id) => ids.has(id))) + }, [result]) + const scrollRef = useRef(null) + const virtualizer = useVirtualizer({ + count: collapsed ? 0 : viewModels.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 28, + overscan: 8, + getItemKey: (index) => `${viewModels[index]!.kind}:${viewModels[index]!.historyItem.id}` + }) const handleToggleExpand = useCallback( (item: GitHistoryItem): void => { @@ -118,23 +130,17 @@ export function GitHistoryPanel({ } return next }) - // Reset the in-flight lock in the click handler, not render; new results can reuse commit IDs with different files. - if (loadedCommitsRef.current.result !== result) { - loadedCommitsRef.current = { result, ids: new Set() } - } - if (!willExpand || !onLoadCommitFiles || loadedCommitsRef.current.ids.has(id)) { + if (!willExpand || !onLoadCommitFiles || loadedCommitsRef.current.has(id)) { return } - loadedCommitsRef.current.ids.add(id) + loadedCommitsRef.current.add(id) setFilesByCommit((prev) => ({ ...prev, [id]: { status: 'loading' } })) onLoadCommitFiles(item) .then((entries) => { setFilesByCommit((prev) => ({ ...prev, [id]: { status: 'ready', entries } })) }) .catch((error: unknown) => { - if (loadedCommitsRef.current.result === result) { - loadedCommitsRef.current.ids.delete(id) - } + loadedCommitsRef.current.delete(id) setFilesByCommit((prev) => ({ ...prev, [id]: { @@ -150,7 +156,7 @@ export function GitHistoryPanel({ })) }) }, - [expanded, onLoadCommitFiles, result] + [expanded, onLoadCommitFiles] ) const stopResize = useCallback((): void => { @@ -261,6 +267,7 @@ export function GitHistoryPanel({ {result && {count}} {result?.hasMore && +} + {worktreeId && }