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 && }